- the Stormwind/Orgrimmar minimap lines are gone, at the cause: a group's boundary tile is authored 1.0 yd larger on that side alone, so touching groups overlap by 2 yd — without the bleed a LINEAR-filtered silhouette loses ≈0.38 texel at every abutment and the black clear reads through as a dashed line; with it, disc black goes 4.49% → 3.69% and no lines, inside the reference's own band - the tile sampler stays LINEAR but filters in gamma space (`GL_SKIP_DECODE_EXT`, as the reference's trace has it) — a terrain underlay and a NEAREST sampler both reproduced the symptom and were both retracted as wrong once wow-re refuted them - the canal-surface line in the world was a second defect: Stormwind's canal groups genuinely overlap (14,416 lattice points claimed wet twice), so the translucent sheet composited twice — the MLIQ `0x80` shared-tile gate now resolves each shared cell to one owner at load, pixel-identical and zero double-claimed points, not one wet point lost - the interior composite is probeable headless now: `WOW_MM_PROBE` synthesises the minimap slot server-less, and an offline rebuilder reproduces the defect and the fix from the shipped art Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
688 lines
36 KiB
Rust
688 lines
36 KiB
Rust
//! MCLQ liquid surfaces → per-chunk water meshes (Phase E; Phase 1 = inland water).
|
||
//!
|
||
//! Vanilla liquid is **MCLQ** — per-MCNK, a 9×9 absolute-height grid + an 8×8 cell-flag grid
|
||
//! (no WotLK MH2O), one 804-byte block per set liquid header bit. [`MclqChunk`] decodes it; we
|
||
//! validated the output **byte-identical** to a hand-decode of the raw payload against
|
||
//! `World\Maps\Azeroth\Azeroth_32_48.adt` (2026-05-31): per-vertex `height`, raw `tile_flags` and
|
||
//! `min/max_height` all match (the on-disk `QLCM` magic sits at the offset; the payload — and the
|
||
//! crate's parse — start 8 bytes in).
|
||
//!
|
||
//! Output is in **raw WoW coords** (+X north, +Y west, +Z up, yards), like [`crate::terrain`]; the
|
||
//! renderer applies the WoW→Bevy transform and ports the `ocean0_s.bls` shader. We emit a flat
|
||
//! surface (normal `(0,0,1)`) of 2 triangles per **wet** cell; dry cells (flag low-nibble `0xf`,
|
||
//! whose verts carry the FLT_MAX sentinel height) are skipped.
|
||
//!
|
||
//! **The cell nibble is the type** (VERIFIED `0x6ba970`/`0x68d9b0`) — the MCNK header bits only say
|
||
//! how many blocks a chunk carries, and their bit→type ordering was never byte-proven. Reading the
|
||
//! type off the header instead is what kept every open-world lava surface in the game from
|
||
//! rendering (Burning Steppes, Searing Gorge, Un'Goro) and what put the ocean texture on 28 coastal
|
||
//! river blocks while dropping the sea beside them. `build_liquid_mesh` runs the cells first for
|
||
//! exactly that reason.
|
||
|
||
use benilla_adt::MclqChunk;
|
||
|
||
use crate::terrain::{snap_to_lattice, UNIT_SIZE};
|
||
|
||
/// Which animated texture set + render path a liquid surface uses (selects the frame set in
|
||
/// `benilla`). The MCLQ (ADT) path emits Still/Ocean/Magma — the reference's three fixed ADT liquid
|
||
/// queues; the WMO MLIQ path additionally emits Slime and Rapids (canals, fountains, the Ironforge
|
||
/// lava, Undercity).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub enum LiquidKind {
|
||
/// Still water — `XTextures\river\lake_a.*` (30 frames). Lakes, ponds, slow rivers, WMO canals.
|
||
Still,
|
||
/// Rapids — `XTextures\river\fast_a.*` (16 frames). Per-cell nibble `8`.
|
||
///
|
||
/// **Nothing in the shipped 1.12.1 data selects it**: a sweep of every MCLQ cell on all 12
|
||
/// terrain maps and every one of the 302 MLIQ-bearing WMO groups finds nibbles `{1, 4, 6}` and
|
||
/// `{0, 2, 3, 4, 6, 7}` respectively — never `8`. Kept because the name table `0x86a000` really
|
||
/// does map slot 8 here and the WMO path binds the raw nibble, so authored data *could*; the ADT
|
||
/// path can't reach it either way (its river queue hard-codes texcache type 4).
|
||
Rapids,
|
||
/// Ocean — `XTextures\ocean\ocean_h.*` (30 frames). Coastal / sea tiles.
|
||
Ocean,
|
||
/// Magma / lava — `XTextures\lava\lava.*`. Opaque and **unlit**: the animated texture IS the body,
|
||
/// with no depth LUT to modulate it by — but still **fogged**, like every liquid batch (VERIFIED
|
||
/// wow-re `liquid-render-state-sided.md` §3/§5, which corrected the earlier
|
||
/// `rf-water-liquid-type-texture-material.md` reading of the `0x68d890` vert-fill: its
|
||
/// `0x3f800000` is an up normal's Z, not a colour or alpha, and it is byte-identical in the river
|
||
/// and ocean fills).
|
||
///
|
||
/// Reached from **both** paths: WMO MLIQ (the Ironforge Great Forge) and — since the ADT magma
|
||
/// queue landed — MCLQ, which is all the open-world lava there is: 124 chunks in Burning Steppes
|
||
/// and Searing Gorge, 4 in Un'Goro. Its UVs are authored per-vertex, not generated
|
||
/// ([`benilla_adt::LiquidVertex::texcoords`]).
|
||
Magma,
|
||
/// Slime — `XTextures\slime\slime.*`. Its own texture on the magma render category, and **code-path
|
||
/// identical to magma** on the WMO side: one handler, no type-2-vs-3 branch anywhere (VERIFIED
|
||
/// wow-re `liquid-render-state-sided.md` §5).
|
||
///
|
||
/// **WMO liquid only** (nibbles `3`/`7`) — Undercity, the Sludge Fields. Not a deferral: the
|
||
/// reference has no ADT queue for slime at all (VERIFIED — `0x68de40` dispatches only its three
|
||
/// queues, types 4/1/6), and no shipped MCLQ cell carries nibble 3 or 7 to feed one.
|
||
Slime,
|
||
}
|
||
|
||
impl LiquidKind {
|
||
/// The liquid type from a per-tile MLIQ flag low nibble (`flag & 0xf`) — the VERIFIED reference
|
||
/// selection (wow-re `rf-water-liquid-type-texture-material.md`, name table `0x86a000`): the
|
||
/// nibble indexes the animated-texture table directly. `0xf` (and any unmapped value) = hole /
|
||
/// no liquid → `None`. `4` is a same-class `lake_a` variant of `0`; `6`/`7` of `2`/`3`.
|
||
pub fn from_nibble(nibble: u8) -> Option<LiquidKind> {
|
||
match nibble & 0xf {
|
||
0 | 4 => Some(LiquidKind::Still),
|
||
1 => Some(LiquidKind::Ocean),
|
||
2 | 6 => Some(LiquidKind::Magma),
|
||
3 | 7 => Some(LiquidKind::Slime),
|
||
8 => Some(LiquidKind::Rapids),
|
||
_ => None, // 5, 9..=0xf — NULL name-table slots / the 0xf hole sentinel
|
||
}
|
||
}
|
||
|
||
/// The kind an **ADT** (MCLQ) cell nibble selects — which is *not* [`Self::from_nibble`].
|
||
///
|
||
/// The reference's ADT liquid render is **three fixed queues, each hard-coding its texcache
|
||
/// type**: `0x6851b0` → type 4 `lake_a` (`mov ecx,4` @`0x68db9d`), `0x685010` → type 1 `ocean_h`
|
||
/// (@`0x68dabb`), `0x6855a0` → type 6 `lava` (@`0x68dcab`). The cell nibble selects queue
|
||
/// *membership* — via the class `nibble & 3` — not the texture, so an ADT nibble 0 and 8 both
|
||
/// draw `lake_a`, and 2 and 6 both draw `lava`. **Slime has no ADT queue at all**: it renders
|
||
/// only on the WMO path, so class 3 is `None` here (VERIFIED wow-re
|
||
/// `rf-water-liquid-type-texture-material.md` + `terrain.md`).
|
||
///
|
||
/// The WMO path is the type-faithful one — it binds `0x68aac0(raw nibble)` — and keeps
|
||
/// [`Self::from_nibble`].
|
||
pub fn from_adt_nibble(nibble: u8) -> Option<LiquidKind> {
|
||
match nibble & 3 {
|
||
0 => Some(LiquidKind::Still),
|
||
1 => Some(LiquidKind::Ocean),
|
||
2 => Some(LiquidKind::Magma),
|
||
_ => None, // class 3 — slime, which the reference never dispatches from an ADT
|
||
}
|
||
}
|
||
|
||
/// Whether this kind renders as an **opaque, unlit** surface (the animated texture is the body
|
||
/// colour, no water swatch / depth ramp / N·L darkening) — magma and slime. Water/ocean use the
|
||
/// depth-swatch `ocean0_s.bls` path instead.
|
||
///
|
||
/// "Fullbright" here means **unlit, not unfogged**: every liquid batch in the reference draws with
|
||
/// GL_FOG enabled, magma and slime included (wow-re `liquid-render-state-sided` §3/§5). Reading it
|
||
/// as "no fog" is what made a submerged slime surface a flat unshaded sheet.
|
||
pub fn is_fullbright(self) -> bool {
|
||
matches!(self, LiquidKind::Magma | LiquidKind::Slime)
|
||
}
|
||
}
|
||
|
||
/// One liquid surface — an MCNK's MCLQ or a WMO group's MLIQ — as a **regular vertex grid** in raw
|
||
/// WoW coords (+Z up), plus the triangle list the renderer draws.
|
||
///
|
||
/// The grid is the primary form: [`Self::grid`] gives its `[cols, rows]` vertex counts,
|
||
/// `positions`/`uvs`/`depths` are that grid row-major (`j·cols + i`), and [`Self::wet`] says which
|
||
/// of the `(cols−1)·(rows−1)` cells carry liquid. `indices` is the render derivative — 2 triangles
|
||
/// per wet cell — so verts under dry cells stay present but unreferenced.
|
||
///
|
||
/// The queries (is this XY wet, how high is the surface here) read the **grid**, not the triangles:
|
||
/// the reference samples the liquid height as a bilinear over the containing cell's four corners
|
||
/// (VERIFIED `0x6b7500` `liquid_height_sample`, wow-re `system/terrain` — transcribed bit-exact
|
||
/// there), which is a cell lookup, not a triangle search. Drawn two-sided (cull off), so winding
|
||
/// is not load-bearing.
|
||
#[derive(Debug, Clone)]
|
||
pub struct LiquidMesh {
|
||
/// Vertex-grid dimensions `[cols, rows]` — the counts along the two grid axes, `cols` fastest.
|
||
/// MCLQ is always `[9, 9]`; MLIQ is the MLIQ header's `xverts`/`yverts` (Blackrock's magma is
|
||
/// `[55, 82]`). Every per-vertex array below is this grid, row-major.
|
||
pub grid: [u32; 2],
|
||
/// Per-**cell** liquid coverage, row-major over `(cols−1) × (rows−1)`: `true` where the tile
|
||
/// nibble says liquid (and, for MCLQ, its four corner heights are real). This is the source of
|
||
/// truth for containment — a liquid grid is sparse (nibble `0xf` = hole), so its bounding box
|
||
/// routinely spans dry ground it never touches (decision 0635).
|
||
pub wet: Vec<bool>,
|
||
/// Per-**cell**, parallel to [`Self::wet`]: the MLIQ tile flag's **`0x80`** bit — "a neighbouring
|
||
/// group also claims this cell". Authored on **both** claimants, never on a cell only one group
|
||
/// covers (measured over `Stormwind.wmo`: 190 of 2685 wet cells, each claimed by exactly two
|
||
/// groups and flagged in both; zero overlap among the `0x80`-clear cells — wow-re
|
||
/// `terrain/scratch/wmo-liquid-shared-tile-gate.md`).
|
||
///
|
||
/// The reference's strip builder emits a tile iff `(f & 0xf) != 0xf` **and** (`f & 0x80 == 0`
|
||
/// **or** this group won the gate) — a per-frame 2-colouring of the portal graph that lets
|
||
/// exactly ONE of the two claimants draw. Without it both surfaces draw, coplanar, and the
|
||
/// overlap band composites the translucent water twice: the visible line across the Stormwind
|
||
/// canals (B141's water half). Resolved model-wide in `benilla_assets::wmo`, not here — a group
|
||
/// file cannot see its neighbours.
|
||
///
|
||
/// Always `false` on the ADT path: MCLQ has no such bit and no such overlap.
|
||
pub shared: Vec<bool>,
|
||
/// Vertex positions `[x, y, z]` in WoW yards — `cols·rows` entries, row-major.
|
||
pub positions: Vec<[f32; 3]>,
|
||
/// Texture UVs parallel to `positions`. No scroll for water.
|
||
///
|
||
/// Generated cell-index UVs (`¼` per cell, tiling) for **water and ocean** — and for WMO liquid,
|
||
/// anchored to model space over the same period. **Magma is different**: its UVs are *authored*
|
||
/// per vertex and read straight off the MCLQ union bytes (`u = s · 3/256`), because the reference's
|
||
/// lava batch is single-stage with a reset texture matrix, so `tc0` is the vertex's own coord
|
||
/// rather than anything the client generates ([`benilla_adt::LiquidVertex::texcoords`]).
|
||
pub uvs: Vec<[f32; 2]>,
|
||
/// Per-vertex liquid swatch coord **V** `0..1` from the MCLQ `SVert` depth byte, parallel to
|
||
/// `positions`. River/lake = `clamp(byte/42)` (VERIFIED `c81768`, saturates ~5 yd); ocean = byte/255
|
||
/// (placeholder — ocean uses a different non-LUT path, see `build_liquid_mesh`). The reference uses
|
||
/// this single V to index the depth swatch for BOTH the body-colour band (shallow→deep water rows)
|
||
/// and the opacity ramp (`colorTex.a` = `127+2·row`, deeper = more opaque).
|
||
///
|
||
/// **Zero on ADT magma, and read by nothing**: those union bytes are texture coords, not a depth
|
||
/// byte, and the reference's magma vert-fill reads no ramp at all (`0x68d890` — no LUT load,
|
||
/// unlike the river/ocean fills). The shader's fullbright branch returns before it ever samples
|
||
/// this, so the zero is a statement, not a value. (The WMO builder fills its own flat
|
||
/// `WMO_WATER_DEPTH_V` for every kind, magma and slime included — equally unread there.)
|
||
pub depths: Vec<f32>,
|
||
/// Triangle indices into `positions` — 6 per wet cell, cells in row-major order. Derived from
|
||
/// [`Self::wet`]; the render form.
|
||
pub indices: Vec<u32>,
|
||
/// The surface's **sound-class nibble** — the majority wet cell's low nibble (terrain), or
|
||
/// the `0x6ba970`-resolved nibble (WMO): `class = nibble & 3`, `FluidSpeed = nibble & 0xc`,
|
||
/// the key the above-water liquid ambient-loop system resolves through `SoundWaterType.dbc`
|
||
/// ([`crate::WaterSoundCatalog`]; wow-re `liquid-ambience-loop.md`, decision 0506). Carried
|
||
/// beside `kind` because the render kind collapses the river speeds (nibbles 0 and 4 both
|
||
/// draw `lake_a`) that the sound table splits (RiverStill 1111 vs RiverSlow 1112).
|
||
pub sound_nibble: u8,
|
||
/// **WMO only** — the MLIQ header's `materialId`, i.e. this pool's index into the owning root's
|
||
/// MOMT array. The reference's INTERIOR water kernel takes its whole body colour from
|
||
/// `MOMT[materialId].diffColor` (see `benilla_wmo::Material::diff_color`), so this is how a pool
|
||
/// finds its own colour; the group file alone cannot resolve it, because MOMT lives in the root.
|
||
/// `None` on the ADT path, which has no such index and no such lookup.
|
||
pub material_id: Option<u16>,
|
||
/// The texture set / render path this surface uses.
|
||
pub kind: LiquidKind,
|
||
}
|
||
|
||
/// 9×9 vertex grid per MCNK.
|
||
const GRID: usize = 9;
|
||
/// 8×8 cell grid per MCNK.
|
||
const CELLS: usize = 8;
|
||
/// Cell-flag low nibble meaning "dry / do not render".
|
||
const DRY_NIBBLE: u8 = 0x0f;
|
||
/// The scale the reference applies to a magma vertex's authored `u16` texture coords:
|
||
/// `u = (s as i32 as f64 · TEXC) as f32`, `TEXC = 0x3c400000 = 3/256`. VERIFIED bit-exact at
|
||
/// `WoW.exe 0x68d890` (`liquid_render_verts`), emulation-diffed over 60 randomized fills in wow-re
|
||
/// `crates/terrain/tests/difftest/fills.rs`. Authored steps run ≈42 units per 4.167-yd cell, so one
|
||
/// lava repeat spans ≈8.5 yd — twice the density of the ¼-per-cell water field.
|
||
const MAGMA_TEXCOORD_SCALE: f32 = f32::from_bits(0x3c40_0000);
|
||
/// Verts under no liquid carry FLT_MAX (`0x7F7FFFFF`) — `is_finite()` is TRUE for it, so gate on
|
||
/// magnitude instead.
|
||
const HEIGHT_SENTINEL: f32 = 1.0e9;
|
||
/// River/lake depth-byte at which the swatch coord `V` saturates to 1.0: `V = clamp(byte/42)`.
|
||
/// VERIFIED from `WoW.exe` — the in-game river/lake water list (`FUN_0068d790`) reads the **steep**
|
||
/// `DAT_00c81768` LUT, built by `FUN_0068c4c0` as `clamp((d/9)/4.6667) = clamp(d/42)` (constants
|
||
/// `0x81028c=1/9`, `0x810380=1/4.6667` static-verified), and the live VBO confirms `V` is multiples
|
||
/// of 1/42 saturating at 1.0. (The gentler `byte/255` = the `DAT_00c7fcd8` LUT on a DIFFERENT list
|
||
/// `FUN_0068d690` that the from-above river path does not use — hence the river middle never reached
|
||
/// the deep/teal swatch row until this. The river depth ramp is ≈8.5 byte/yd, so V saturates at ≈5 yd.)
|
||
const RIVER_DEPTH_V_SATURATION: f32 = 42.0;
|
||
|
||
/// Build a flat liquid surface mesh from one parsed MCLQ **block** at MCNK origin `position` (the
|
||
/// chunk header's raw WoW `[x, y, z]`). A chunk can hold more than one block — see [`MclqChunk`].
|
||
///
|
||
/// `None` when the block has no wet cell, or when its cells are slime, which the reference has no
|
||
/// ADT queue for ([`LiquidKind::from_adt_nibble`]).
|
||
pub(crate) fn build_liquid_mesh(mclq: &MclqChunk, position: [f32; 3]) -> Option<LiquidMesh> {
|
||
if mclq.vertices.len() < GRID * GRID || mclq.tile_flags.len() < CELLS * CELLS {
|
||
return None;
|
||
}
|
||
|
||
// ── Cells first, because the CELLS decide the kind. ──
|
||
// 2 tris per wet cell. Skip dry cells (low nibble 0xf) and any cell with a sentinel-height
|
||
// corner (belt-and-suspenders).
|
||
let mut indices = Vec::with_capacity(CELLS * CELLS * 6);
|
||
let mut wet = vec![false; CELLS * CELLS];
|
||
// Wet-cell nibble tally → the block's majority nibble, which is BOTH its render kind and its
|
||
// sound class (`LiquidMesh::sound_nibble`). Counted over genuinely-wet cells only, so it
|
||
// describes what is actually drawn.
|
||
let mut nibble_counts = [0u32; 16];
|
||
for row in 0..CELLS {
|
||
for col in 0..CELLS {
|
||
let flag = mclq.tile_flags[row * CELLS + col] & 0x0f;
|
||
if flag == DRY_NIBBLE {
|
||
continue;
|
||
}
|
||
let tl = (row * GRID + col) as u32;
|
||
let tr = tl + 1;
|
||
let bl = ((row + 1) * GRID + col) as u32;
|
||
let br = bl + 1;
|
||
// Guard against a wet cell whose corner is the sentinel/NaN (data anomaly) — check the
|
||
// ORIGINAL heights, not the sanitized `positions`. Wet cells normally carry real heights.
|
||
if [tl, tr, bl, br].iter().any(|&i| {
|
||
let raw = mclq.vertices[i as usize].height;
|
||
!raw.is_finite() || raw.abs() >= HEIGHT_SENTINEL
|
||
}) {
|
||
continue;
|
||
}
|
||
nibble_counts[flag as usize] += 1;
|
||
// The cell is liquid AND its corners are real — the same gate the triangles pass, so
|
||
// the grid the queries read and the mesh the renderer draws can never disagree.
|
||
wet[row * CELLS + col] = true;
|
||
indices.extend_from_slice(&[tl, bl, br, tl, br, tr]);
|
||
}
|
||
}
|
||
if indices.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
// The kind comes from the CELL NIBBLE — the VERIFIED type source (`0x6ba970`/`0x68d9b0`) — not
|
||
// from the MCNK header bits, whose bit→type ordering was never byte-proven and which mislabelled
|
||
// every two-block river-mouth chunk as ocean. In shipped data a block's wet cells are of one
|
||
// class throughout, so the majority is the block; the tally is what breaks a tie.
|
||
let sound_nibble = nibble_counts
|
||
.iter()
|
||
.enumerate()
|
||
.max_by_key(|(_, &c)| c)
|
||
.map(|(n, _)| n as u8)
|
||
.unwrap_or(0);
|
||
let kind = LiquidKind::from_adt_nibble(sound_nibble)?;
|
||
|
||
// Depth-byte → swatch coord `V` divisor (VERIFIED). River/lake (the `c81768`/`FUN_0068d790` path)
|
||
// saturates at byte 42 = ~5 yd, so the channel middle reaches the deep/teal swatch row. Ocean uses
|
||
// a DIFFERENT mechanism (`FUN_0068d890` reads explicit per-vertex MCLQ UVs, not a depth LUT) — left
|
||
// at /255 here pending its own RE + an in-game A/B; an ocean A/B has not been done since this fix.
|
||
let depth_v_div = match kind {
|
||
LiquidKind::Ocean => 255.0,
|
||
_ => RIVER_DEPTH_V_SATURATION,
|
||
};
|
||
// Magma reads its UVs off the vertex and its depth off nothing at all (see `LiquidMesh::uvs` /
|
||
// `::depths`); every other kind generates cell-index UVs and ramps by the depth byte.
|
||
let authored_uvs = kind == LiquidKind::Magma;
|
||
let [wx, wy, _wz] = position;
|
||
|
||
// 81 grid verts in raw WoW coords. Linear index `n`: row = n/9 (steps south, −X), col = n%9
|
||
// (steps east, −Y) — the same orientation as the MCVT outer grid, so the water lattice lines
|
||
// up with the terrain it sits in. Z is the MCLQ per-vertex absolute world height. Snap X/Y to
|
||
// the global lattice exactly as terrain does.
|
||
let mut positions = Vec::with_capacity(GRID * GRID);
|
||
let mut uvs = Vec::with_capacity(GRID * GRID);
|
||
let mut depths = Vec::with_capacity(GRID * GRID);
|
||
for n in 0..GRID * GRID {
|
||
let row = (n / GRID) as f32;
|
||
let col = (n % GRID) as f32;
|
||
// Dry/no-liquid verts carry the FLT_MAX (0x7F7FFFFF) sentinel (and `is_finite()` is TRUE for
|
||
// it). They're never indexed (only wet cells emit tris), but they stay in `positions`, so a
|
||
// raw sentinel would blow the mesh AABB out to ~1e38 — which makes Bevy's visibility culling
|
||
// drop the whole chunk (the "partial water chunks vanish, fully-wet ocean is fine" bug, since
|
||
// only mixed-wet/dry chunks contain sentinels). Substitute the chunk's `min_height` for any
|
||
// non-finite/sentinel height so the AABB stays tight; the value is invisible (unreferenced).
|
||
let raw = mclq.vertices[n].height;
|
||
let h = if raw.is_finite() && raw.abs() < HEIGHT_SENTINEL {
|
||
raw
|
||
} else {
|
||
mclq.min_height
|
||
};
|
||
positions.push([
|
||
snap_to_lattice(wx - row * UNIT_SIZE),
|
||
snap_to_lattice(wy - col * UNIT_SIZE),
|
||
h,
|
||
]);
|
||
if authored_uvs {
|
||
// `u = (s as i32 as f64 · 3/256) as f32` — VERIFIED bit-exact `0x68d890`. Unsigned: the
|
||
// reference widens the u16 to i32, so 0xffff is 65535, never −1.
|
||
let [s, t] = mclq.vertices[n].texcoords();
|
||
let scale = f64::from(MAGMA_TEXCOORD_SCALE);
|
||
uvs.push([
|
||
((f64::from(s)) * scale) as f32,
|
||
((f64::from(t)) * scale) as f32,
|
||
]);
|
||
depths.push(0.0);
|
||
} else {
|
||
uvs.push([col * 0.25, row * 0.25]);
|
||
// SVert depth byte (water/ocean: union_data[0]) → swatch coord V (0..1). River/lake saturate
|
||
// at byte 42 (`clamp(byte/42)`, VERIFIED `c81768`) so the channel middle hits the deep/teal
|
||
// swatch row; ocean at /255 (see `depth_v_div`). The SAME V drives both the body-colour depth
|
||
// band and the opacity ramp on the shader side (one swatch row → colour + alpha).
|
||
depths.push((mclq.vertices[n].depth_byte() as f32 / depth_v_div).clamp(0.0, 1.0));
|
||
}
|
||
}
|
||
|
||
Some(LiquidMesh {
|
||
grid: [GRID as u32, GRID as u32],
|
||
shared: vec![false; wet.len()],
|
||
wet,
|
||
positions,
|
||
uvs,
|
||
depths,
|
||
indices,
|
||
sound_nibble,
|
||
material_id: None, // ADT liquid has no MOMT to index
|
||
kind,
|
||
})
|
||
}
|
||
|
||
impl LiquidMesh {
|
||
/// Drop the cells `keep` rejects (indexed like [`Self::wet`]), rebuilding [`Self::indices`].
|
||
/// The vertex arrays are left alone — a vertex no triangle references costs nothing to keep and
|
||
/// the grid stays addressable by cell index.
|
||
///
|
||
/// This is how the MLIQ [`Self::shared`] gate is applied once the owning model can see every
|
||
/// group at once.
|
||
pub fn retain_cells(&mut self, keep: impl Fn(usize) -> bool) {
|
||
let (cols, rows) = (self.grid[0] as usize, self.grid[1] as usize);
|
||
let (xt, yt) = (cols.saturating_sub(1), rows.saturating_sub(1));
|
||
let mut dropped = false;
|
||
for c in 0..self.wet.len() {
|
||
if self.wet[c] && !keep(c) {
|
||
self.wet[c] = false;
|
||
dropped = true;
|
||
}
|
||
}
|
||
if !dropped {
|
||
return;
|
||
}
|
||
self.indices.clear();
|
||
for ty in 0..yt {
|
||
for tx in 0..xt {
|
||
if !self.wet[ty * xt + tx] {
|
||
continue;
|
||
}
|
||
let tl = (ty * cols + tx) as u32;
|
||
let (tr, bl) = (tl + 1, ((ty + 1) * cols + tx) as u32);
|
||
let br = bl + 1;
|
||
self.indices.extend_from_slice(&[tl, bl, br, tl, br, tr]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
|
||
use benilla_adt::{parse_adt, ParsedAdt};
|
||
|
||
use super::*;
|
||
|
||
/// Every liquid mesh a real tile builds, with the MCNK origin each came from.
|
||
fn tile_liquids(chain: &mut crate::Chain, map: &str, tx: u32, ty: u32) -> Vec<LiquidMesh> {
|
||
let bytes = chain
|
||
.read_file(&format!("World\\Maps\\{map}\\{map}_{tx}_{ty}.adt"))
|
||
.expect("read adt");
|
||
let ParsedAdt::Root(root) =
|
||
parse_adt(&mut std::io::Cursor::new(&bytes[..])).expect("parse adt");
|
||
root.mcnk_chunks
|
||
.iter()
|
||
.flat_map(|m| {
|
||
m.liquids
|
||
.iter()
|
||
.filter_map(|lq| build_liquid_mesh(lq, m.header.position))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Whether a mesh's **wet** footprint covers a raw WoW `(x, y)`, and at what surface height —
|
||
/// the containment the swim/submersion queries do, done here off the grid directly.
|
||
fn wet_at(m: &LiquidMesh, x: f32, y: f32) -> Option<f32> {
|
||
let cols = m.grid[0] as usize;
|
||
for (c, _) in m.wet.iter().enumerate().filter(|(_, &w)| w) {
|
||
let (row, col) = (c / (cols - 1), c % (cols - 1));
|
||
let corners = [
|
||
row * cols + col,
|
||
row * cols + col + 1,
|
||
(row + 1) * cols + col,
|
||
(row + 1) * cols + col + 1,
|
||
];
|
||
let xs: Vec<f32> = corners.iter().map(|&i| m.positions[i][0]).collect();
|
||
let ys: Vec<f32> = corners.iter().map(|&i| m.positions[i][1]).collect();
|
||
let (x0, x1) = (
|
||
xs.iter().cloned().fold(f32::MAX, f32::min),
|
||
xs.iter().cloned().fold(f32::MIN, f32::max),
|
||
);
|
||
let (y0, y1) = (
|
||
ys.iter().cloned().fold(f32::MAX, f32::min),
|
||
ys.iter().cloned().fold(f32::MIN, f32::max),
|
||
);
|
||
if (x0..=x1).contains(&x) && (y0..=y1).contains(&y) {
|
||
return Some(corners.iter().map(|&i| m.positions[i][2]).sum::<f32>() / 4.0);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// **B21 — "there should be lava here", `.go xyz -7845.99 -1065.50 123.60 0`.**
|
||
///
|
||
/// Open-world lava is MCLQ magma, and the ADT mesher used to return `None` for it, so every
|
||
/// outdoor lava surface in the game was silently absent while Blackrock's (WMO MLIQ) rendered
|
||
/// fine — exactly the shape of the report. The pin is Burning Steppes, tile 33_46.
|
||
///
|
||
/// This is the whole of it: a sweep of all 12 shipped terrain maps finds MCLQ magma on five
|
||
/// tiles only — 124 chunks across Burning Steppes/Searing Gorge and 4 in Un'Goro — and no MCLQ
|
||
/// slime anywhere at all.
|
||
#[test]
|
||
fn b21_burning_steppes_lava_builds_a_magma_surface_at_the_reported_pin() {
|
||
let data = crate::wow_data_or_skip!();
|
||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||
let meshes = tile_liquids(&mut chain, "Azeroth", 33, 46);
|
||
|
||
let magma: Vec<&LiquidMesh> = meshes
|
||
.iter()
|
||
.filter(|m| m.kind == LiquidKind::Magma)
|
||
.collect();
|
||
assert_eq!(magma.len(), 64, "Burning Steppes tile 33_46 magma chunks");
|
||
|
||
// The reported spot itself is wet, with lava — not merely "somewhere on this tile".
|
||
let (px, py, pz) = (-7845.99f32, -1065.50, 123.60);
|
||
let hit = magma
|
||
.iter()
|
||
.find_map(|m| wet_at(m, px, py))
|
||
.expect("the reported pin stands on a magma surface");
|
||
assert!(
|
||
(hit - pz).abs() < 6.0,
|
||
"lava surface {hit} sits at the reported eye height {pz}"
|
||
);
|
||
|
||
// Fullbright + opaque, and carrying the lava-flow sound class (nibble 6: class 2 = magma,
|
||
// FluidSpeed 4 → LavaFlowLoop) — the whole magma contract, not just a mesh that exists.
|
||
assert!(magma.iter().all(|m| m.kind.is_fullbright()));
|
||
assert!(
|
||
magma.iter().all(|m| m.sound_nibble == 6),
|
||
"shipped ADT magma is nibble 6 throughout"
|
||
);
|
||
// No ADT slime exists to build, and the reference has no queue for it if it did.
|
||
assert!(
|
||
!meshes.iter().any(|m| m.kind == LiquidKind::Slime),
|
||
"slime never renders from an ADT"
|
||
);
|
||
}
|
||
|
||
/// Lava's UVs are **authored per vertex**, not generated — and authored *world-continuously*, so
|
||
/// a chunk's east edge repeats its neighbour's west edge exactly. That continuity is the check
|
||
/// that the `u16 → ·3/256` read (VERIFIED `0x68d890`) is being applied to the right bytes in the
|
||
/// right order: get the scale wrong and it still tiles, get the *field* wrong and the seam shows.
|
||
#[test]
|
||
fn magma_uvs_come_from_the_vertex_and_run_continuous_across_chunks() {
|
||
let data = crate::wow_data_or_skip!();
|
||
if !data.is_dir() {
|
||
return;
|
||
}
|
||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||
let magma: Vec<LiquidMesh> = tile_liquids(&mut chain, "Azeroth", 33, 46)
|
||
.into_iter()
|
||
.filter(|m| m.kind == LiquidKind::Magma)
|
||
.collect();
|
||
|
||
// Not the generated ¼-per-cell field: authored steps are ≈42 units ≈ 0.49 UV per cell.
|
||
let stepped = magma.iter().any(|m| {
|
||
let du = (m.uvs[1][0] - m.uvs[0][0]).abs();
|
||
du > 0.001 && (du - 0.25).abs() > 0.05
|
||
});
|
||
assert!(
|
||
stepped,
|
||
"magma UVs are authored, not the water cell-index UVs"
|
||
);
|
||
|
||
// Two chunks adjacent along −Y: the west one's col-8 vertex column IS the east one's col-0.
|
||
let mut shared = 0;
|
||
for a in &magma {
|
||
for b in &magma {
|
||
let (pa, pb) = (a.positions[8], b.positions[0]);
|
||
if (pa[0] - pb[0]).abs() < 0.01 && (pa[1] - pb[1]).abs() < 0.01 {
|
||
for row in 0..9 {
|
||
let (ua, ub) = (a.uvs[row * 9 + 8], b.uvs[row * 9]);
|
||
// Sentinel-height (dry) verts carry no authored coord — skip those.
|
||
if ua == [0.0, 0.0] || ub == [0.0, 0.0] {
|
||
continue;
|
||
}
|
||
assert!(
|
||
(ua[0] - ub[0]).abs() < 1e-4 && (ua[1] - ub[1]).abs() < 1e-4,
|
||
"shared lava edge vertex must carry one UV: {ua:?} vs {ub:?}"
|
||
);
|
||
shared += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert!(shared > 0, "no shared lava chunk edge found to check");
|
||
}
|
||
|
||
/// **The sweep's second find: an MCNK can carry more than one MCLQ block**, one per set liquid
|
||
/// header bit (VERIFIED wow-re `adt-format.md`). 28 shipped Azeroth chunks are river mouths that
|
||
/// carry two — the stream *and* the sea beneath it — and reading only the first dropped the sea
|
||
/// while the old header-bit priority ("ocean wins") labelled the river block ocean.
|
||
///
|
||
/// Hillsbrad's tile 33_33 is one. Both blocks must survive, each with its own kind and height.
|
||
#[test]
|
||
fn a_river_mouth_chunk_builds_both_its_river_and_its_sea() {
|
||
let data = crate::wow_data_or_skip!();
|
||
if !data.is_dir() {
|
||
return;
|
||
}
|
||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||
let bytes = chain
|
||
.read_file("World\\Maps\\Azeroth\\Azeroth_33_33.adt")
|
||
.expect("read adt");
|
||
let ParsedAdt::Root(root) =
|
||
parse_adt(&mut std::io::Cursor::new(&bytes[..])).expect("parse adt");
|
||
|
||
let two_block: Vec<&benilla_adt::McnkChunk> = root
|
||
.mcnk_chunks
|
||
.iter()
|
||
.filter(|m| m.liquids.len() > 1)
|
||
.collect();
|
||
assert!(
|
||
!two_block.is_empty(),
|
||
"Azeroth_33_33 carries river-mouth chunks with two liquid blocks"
|
||
);
|
||
|
||
for mcnk in two_block {
|
||
assert_eq!(mcnk.liquids.len(), 2, "two set liquid bits ⇒ two blocks");
|
||
let built: Vec<LiquidMesh> = mcnk
|
||
.liquids
|
||
.iter()
|
||
.filter_map(|lq| build_liquid_mesh(lq, mcnk.header.position))
|
||
.collect();
|
||
assert_eq!(built.len(), 2, "both blocks mesh");
|
||
// Disk order is header-bit order: bit 2 (river) then bit 3 (ocean) — and the KIND comes
|
||
// from each block's own cell nibble, so the river no longer reads as ocean.
|
||
assert_eq!(built[0].kind, LiquidKind::Still, "block 0 is the stream");
|
||
assert_eq!(built[1].kind, LiquidKind::Ocean, "block 1 is the sea");
|
||
let sea = built[1].positions[built[1].indices[0] as usize][2];
|
||
let river = built[0].positions[built[0].indices[0] as usize][2];
|
||
assert!(
|
||
river > sea + 1.0,
|
||
"the stream ({river}) sits above sea level ({sea})"
|
||
);
|
||
// **The invariant that makes spawning both safe at a river mouth.**
|
||
// `liquid::liquid_at` resolves overlapping footprints by taking the LOWEST surface
|
||
// (decision 0634/0701 — the volume you are actually in when two are stacked). If a
|
||
// river cell and an ocean cell covered the same XY, the sea at z≈0 would win under a
|
||
// stream 5 yd above it and the player would read as dry while standing in the water.
|
||
// At every real river mouth the two blocks are authored **disjoint** — each cell
|
||
// belongs to exactly one — so they never compete for a position.
|
||
//
|
||
// Swept game-wide: 28 two-block chunks, and the only 4 whose cells overlap are in
|
||
// Kalimdor tile 26_54 (a flat −23.47 sheet under a full-chunk sea at 0.0, in the dead
|
||
// water off the south-west corner). That patch read the same way *before* multi-block
|
||
// parsing — the first block was all there was — so it is a named residual of the
|
||
// lowest-wins rule, not a regression. See the decision record.
|
||
for (cell, (&r, &o)) in built[0].wet.iter().zip(&built[1].wet).enumerate() {
|
||
assert!(
|
||
!(r && o),
|
||
"cell {cell} is wet in BOTH blocks — the stacked-surface case liquid_at \
|
||
resolves lowest-wins, which would read this stream as dry"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Golden test against a real Elwynn tile: Crystal Lake area `Azeroth_32_48.adt` has 42 MCLQ
|
||
/// water chunks (all river/still, surface ≈ 143.99 yd). Skips when the client isn't present.
|
||
#[test]
|
||
fn parses_elwynn_lake_tile() {
|
||
let data = crate::wow_data_or_skip!();
|
||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||
let bytes = chain
|
||
.read_file("World\\Maps\\Azeroth\\Azeroth_32_48.adt")
|
||
.expect("read Azeroth_32_48.adt");
|
||
let ParsedAdt::Root(root) =
|
||
parse_adt(&mut std::io::Cursor::new(&bytes[..])).expect("parse adt");
|
||
|
||
let mut meshes = Vec::new();
|
||
for mcnk in &root.mcnk_chunks {
|
||
for mclq in &mcnk.liquids {
|
||
if let Some(m) = build_liquid_mesh(mclq, mcnk.header.position) {
|
||
meshes.push(m);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 42 water chunks on this tile, every one still river water.
|
||
assert_eq!(
|
||
meshes.len(),
|
||
42,
|
||
"expected 42 liquid chunks on Azeroth_32_48"
|
||
);
|
||
assert!(
|
||
meshes.iter().all(|m| m.kind == LiquidKind::Still),
|
||
"Crystal Lake is all still water"
|
||
);
|
||
|
||
for m in &meshes {
|
||
assert_eq!(m.positions.len(), 81, "9×9 grid");
|
||
assert_eq!(m.uvs.len(), 81, "uv per vertex");
|
||
assert_eq!(m.depths.len(), 81, "depth per vertex");
|
||
// EVERY vertex (even unreferenced dry-cell verts) must be finite + sane, or the mesh AABB
|
||
// blows up (dry verts carry the FLT_MAX sentinel) and Bevy frustum-culls the whole chunk —
|
||
// the "partial water chunks vanish, fully-wet ocean is fine" bug.
|
||
for p in &m.positions {
|
||
assert!(
|
||
p[2].is_finite() && p[2].abs() < 10_000.0,
|
||
"all liquid verts sane for a clean AABB, got z={}",
|
||
p[2]
|
||
);
|
||
}
|
||
assert!(!m.indices.is_empty(), "at least one wet cell");
|
||
assert_eq!(m.indices.len() % 6, 0, "2 tris per wet cell");
|
||
// Indices in range, and every referenced vertex has a finite, sane height — no
|
||
// FLT_MAX sentinel leaked in (the dry-cell gate worked). Elwynn water bodies sit at
|
||
// various elevations (Crystal Lake ≈ 144 yd, streams lower), all in a plausible band.
|
||
assert!(m.indices.iter().all(|&i| (i as usize) < m.positions.len()));
|
||
for &i in &m.indices {
|
||
let z = m.positions[i as usize][2];
|
||
assert!(
|
||
z.is_finite() && z.abs() < 10_000.0,
|
||
"referenced height {z} sane"
|
||
);
|
||
assert!(
|
||
(50.0..300.0).contains(&z),
|
||
"Elwynn water elevation {z} plausible"
|
||
);
|
||
}
|
||
// Within one chunk a water surface is near-planar (a lake; a gentle stream slopes a
|
||
// little across the 33-yd chunk but not wildly).
|
||
let zs: Vec<f32> = m
|
||
.indices
|
||
.iter()
|
||
.map(|&i| m.positions[i as usize][2])
|
||
.collect();
|
||
let (zmin, zmax) = zs
|
||
.iter()
|
||
.fold((f32::MAX, f32::MIN), |(lo, hi), &z| (lo.min(z), hi.max(z)));
|
||
assert!(
|
||
zmax - zmin < 30.0,
|
||
"per-chunk surface roughly flat ({zmin}..{zmax})"
|
||
);
|
||
}
|
||
}
|
||
}
|