were measuring nothing
The last 11. The realm reships `World\Maps\`, `HumanMale.m2` and
`TaxiPathNode.dbc`, so these tests had lost the *examples* they picked without
losing the properties they were for. Two had quietly stopped testing anything.
**A guard that was a proxy, not a fact.** The lava seam test skipped a vertex
whose UV was `[0.0, 0.0]`, standing in for "this vertex is dry". The realm's
lava leaves non-zero texcoords on undrawn verts, so 121 meaningless pairs were
entering the compare and one of them failed. Gating on the mesh's own `wet` map
instead leaves 277 pairs — the verts both neighbouring blocks actually draw —
and every one of them agrees. The invariant was never in doubt; the guard was.
**A keyless bone read as an empty band.** `empty_band_poses_the_bone` asserted
HumanMale's waist (bone 27) carries exactly one clamp key in Run. In the realm's
model bone 27 has no rotation or translation keys in *any* of its 156 sequences,
so there was nothing to clamp and the assertion was reading a keyless bone. The
subject is the five finger bones 100..=104 now: each appears in every one of the
156 sequences with exactly one clamp key at t = 0, which is the rule — an empty
band poses the bone and never drops it.
**The skeleton was renumbered, and the poses came with it.** 1.12's finger bone
102 is unposed at the HandsClosed frame; bone 47 carries the curl, within 0.004
of 1.12's `[0.2536, -0.016, 0.0577, 0.9654]`. Same for the eyelid: bone 75 ->
bone 24, still scale-only, still a 6633 ms loop that opens at t=0 and shuts
mid-loop. That the *pose* survived is what says renumbering rather than
re-animation, so the window arithmetic is still what is under test. (The model
does have HandsClosed throughout — `parse_m2_animations` not surfacing it is
exactly why the raw reader exists.) Ten new global-sequence bones came with it,
all parks: six 33 ms two-key constant scales and four rotation channels with
empty tables, recorded so a real loop appearing among them reads as a change.
**Tiles chosen instead of inherited.** `examples/liquid_scan.rs` walks a map and
reports which tiles still have each liquid property, so a re-recorded test names
a tile that was picked:
- the river mouth moved to `Azeroth_33_38`, three Still-over-Ocean chunks, all
disjoint — 1.12's 33_33 has no two-block chunk left. Four tiles *do* overlap
and are kept out deliberately: Kalimdor 26_54, 41_29, 41_30 and the realm's
own Azeroth 36_51, where 1.12 had only 26_54.
- the ocean depth divisor moved to `Azeroth_29_54`: 13210 depth bytes inside the
ramp and not one pinned at 255, so a wrong divisor cannot hide behind
saturation. 1.12's 41_41 carries no ocean at all now.
**The impassable band was thinned.** `Azeroth_33_44` authors 17 impassable
chunks where 1.12's copy had 48, and the chunk 1.46 yd east of the reported
`.go xyz -6601.98 -531.87 335.60` is among the ones it dropped — so the data no
longer walls the reported spot. The `Some(true)` leg would have stopped being
tested, so it moved to a column inside one of the 17 the realm does flag.
**The transport goldens no longer describe this client.** `TaxiPathNode.dbc` is
re-authored: 11219 rows against 9582, and 7975 rows kept their id while changing
path, index *and* coordinates. Path 436 is gone; the eight survivors miss
vmangos's 1.12 periods by −290815 to +271184 ms, and no transport path crosses
continents any longer. Closing that needs the realm's own transport periods,
which only live observation gives — so the calibration is a report now, with the
deltas printed on the same variant grid a future observation can be compared on.
What is still assertable is asserted: `client_period_ms` and the timetable's own
`period_ms` are one number on all eight. The two map-invariant tests never
needed a server period at all, so they run on taxi path 72 — 37 nodes over maps
{0, 1}, the only shipped path that still spans both — and additionally assert
that every transport golden is single-map, so none of them can silently stand in
for it again.
**benilla-formats: 445 passed, 0 failed.** `cargo fmt --all` and
`clippy -p benilla-formats --all-targets` clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
651 lines
28 KiB
Rust
651 lines
28 KiB
Rust
//! MCLQ liquid surfaces to per-chunk liquid meshes, in raw WoW coords (+X north, +Y west, +Z up).
|
||
//!
|
||
//! An MCNK carries one 804-byte MCLQ block per set liquid header bit, each a 9×9 absolute-height
|
||
//! grid and an 8×8 cell-flag grid. Each wet cell is two triangles; a dry cell (nibble `0xf`,
|
||
//! corners at the FLT_MAX sentinel) is skipped. The cell nibble is the liquid type (`0x6ba970`,
|
||
//! `0x68d9b0`); the header bits only count the blocks.
|
||
|
||
use benilla_adt::MclqChunk;
|
||
|
||
use crate::terrain::{snap_to_lattice, UNIT_SIZE};
|
||
|
||
/// A liquid's animated texture set and render path; ADT liquid is only Still, Ocean or Magma.
|
||
#[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), nibble 8 in the name table `0x86a000`. No
|
||
/// shipped MCLQ or MLIQ cell carries 8, and the ADT path cannot reach it.
|
||
Rapids,
|
||
/// Ocean, `XTextures\ocean\ocean_h.*` (30 frames).
|
||
Ocean,
|
||
/// Magma, `XTextures\lava\lava.*`: opaque and unlit, but fogged like every liquid batch, since
|
||
/// no liquid setup overrides the device's fog-on default (`0x593d18`). Both paths reach it; its
|
||
/// ADT UVs are authored per vertex ([`benilla_adt::LiquidVertex::texcoords`]).
|
||
Magma,
|
||
/// Slime, `XTextures\slime\slime.*`: magma's render path with its own texture (one handler,
|
||
/// `0x6b68f0`). WMO only (nibbles 3 and 7): the ADT dispatch `0x68de40` has no slime queue.
|
||
Slime,
|
||
}
|
||
|
||
impl LiquidKind {
|
||
/// The kind a WMO tile nibble selects: it indexes the animated-texture name table `0x86a000`
|
||
/// directly, with 4, 6 and 7 as variants of 0, 2 and 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: empty name-table slots and the 0xf hole
|
||
}
|
||
}
|
||
|
||
/// The kind an ADT cell nibble selects, by its class `nibble & 3`: the reference draws ADT
|
||
/// liquid in three queues with fixed textures, `lake_a` (`0x6851b0`, `0x68db9d`), `ocean_h`
|
||
/// (`0x685010`, `0x68dabb`) and `lava` (`0x6855a0`, `0x68dcab`), and none for slime. The WMO
|
||
/// path binds the raw nibble instead (`0x68aac0`), as [`Self::from_nibble`] does.
|
||
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: no ADT queue
|
||
}
|
||
}
|
||
|
||
/// Whether this kind is opaque and unlit, its texture the body colour (magma, slime); water and
|
||
/// ocean take the `ocean0_s.bls` depth-swatch path. Unlit is not unfogged (`0x593d18`).
|
||
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: a regular vertex grid in raw WoW
|
||
/// coords and the triangles over its wet cells. Queries read the grid, as the reference samples
|
||
/// liquid height bilinearly over the containing cell's corners (`0x6b7500`). Drawn two-sided, as
|
||
/// every reference liquid pass turns culling off (`0x6851da`, `0x6b6302`).
|
||
#[derive(Debug, Clone)]
|
||
pub struct LiquidMesh {
|
||
/// Vertex counts `[cols, rows]`, cols fastest: `[9, 9]` for MCLQ, the header's
|
||
/// `xverts`/`yverts` for MLIQ. Every per-vertex array is this grid, row-major.
|
||
pub grid: [u32; 2],
|
||
/// Per-cell coverage over `(cols−1)·(rows−1)`: the nibble says liquid and, for MCLQ, the
|
||
/// corners are real. The containment truth, since the grid's bounds span dry cells.
|
||
pub wet: Vec<bool>,
|
||
/// Per cell, the MLIQ tile flag's `0x80`: another group claims the cell too. The reference
|
||
/// draws it only for the group winning a per-frame 2-colouring of the portal graph, so the
|
||
/// translucent overlap draws once; applied in `benilla_assets::wmo`, `false` on the ADT path.
|
||
pub shared: Vec<bool>,
|
||
/// Vertex positions in WoW yards, `cols·rows` of them.
|
||
pub positions: Vec<[f32; 3]>,
|
||
/// Texture UVs: generated at ¼ per cell for water and ocean (WMO liquid in model space, same
|
||
/// period); ADT magma's are authored per vertex (`u = s · 3/256`), since the reference's lava
|
||
/// batch is single-stage with a reset texture matrix.
|
||
pub uvs: Vec<[f32; 2]>,
|
||
/// Per-vertex swatch coord V (0..1) from the MCLQ depth byte: `clamp(byte/42)` for river and
|
||
/// lake, `clamp(byte/255)` for ocean. The reference indexes the depth swatch with this one V
|
||
/// for both the body colour and the opacity ramp (`colorTex.a = 127 + 2·row`). 0 on ADT magma,
|
||
/// whose union bytes are texture coords and whose vert-fill (`0x68d890`) reads no ramp.
|
||
pub depths: Vec<f32>,
|
||
/// Triangle indices, 6 per wet cell in row-major order, derived from [`Self::wet`].
|
||
pub indices: Vec<u32>,
|
||
/// The sound nibble, class `& 3` and FluidSpeed `& 0xc`: the majority wet cell's (ADT) or the
|
||
/// `0x6ba970` one (WMO), keyed through `SoundWaterType.dbc` (`0x462a40`). Kept beside `kind`,
|
||
/// which merges the river speeds (nibbles 0 and 4) that the sound table splits.
|
||
pub sound_nibble: u8,
|
||
/// WMO only: the MLIQ header's `materialId`, an index into the root's MOMT, whose `diffColor`
|
||
/// is the reference's interior water body colour. `None` on the ADT path.
|
||
pub material_id: Option<u16>,
|
||
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 reference's scale on a magma vertex's authored `u16` texcoords, `0x3c400000` (3/256):
|
||
/// `u = (s as i32 as f64 · scale) as f32`, bit-exact to `0x68d890`.
|
||
const MAGMA_TEXCOORD_SCALE: f32 = f32::from_bits(0x3c40_0000);
|
||
/// Verts under no liquid carry FLT_MAX (`0x7F7FFFFF`), which is finite, so gate on magnitude.
|
||
const HEIGHT_SENTINEL: f32 = 1.0e9;
|
||
/// The river and lake depth byte at which V saturates, `clamp(byte/42)`: the LUT `0xc81768`, built
|
||
/// by `0x68c4c0` as `clamp((d/9)/4.6667)` (`0x81028c`, `0x810380`) and read by the river fill
|
||
/// `0x68d790`. About 5 yd of water.
|
||
const RIVER_DEPTH_V_SATURATION: f32 = 42.0;
|
||
/// The ocean depth byte at which V saturates, `clamp(byte/255)`: the ocean's own LUT `0xc7fcd8`,
|
||
/// built beside the river's (`0x68c57c`) and read by the ocean fill `0x68d690` at `0x68d718`, as
|
||
/// the river fill reads its own at `0x68d818`. The divisors differ because the bytes do: over every
|
||
/// Azeroth and Kalimdor MCLQ block (`examples/liquid_depth_census`) the sea is authored at 1.72
|
||
/// byte/yd against the river's 8.96, and 83.4 % of ocean vertices sit at 255.
|
||
const OCEAN_DEPTH_V_SATURATION: f32 = 255.0;
|
||
|
||
/// `WOW_OCEAN_DEPTH_DIV=<divisor>` overrides [`OCEAN_DEPTH_V_SATURATION`] for one run, read once;
|
||
/// 42 puts the river's ramp on the sea for a side-by-side at a shoreline.
|
||
fn ocean_depth_v_divisor() -> f32 {
|
||
static DIV: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
|
||
*DIV.get_or_init(|| {
|
||
std::env::var("WOW_OCEAN_DEPTH_DIV")
|
||
.ok()
|
||
.and_then(|v| v.parse::<f32>().ok())
|
||
.filter(|d| *d > 0.0)
|
||
.unwrap_or(OCEAN_DEPTH_V_SATURATION)
|
||
})
|
||
}
|
||
|
||
/// Mesh one MCLQ block at the MCNK header's raw WoW `position`; `None` when no cell is wet or the
|
||
/// cells are slime, which has no ADT queue.
|
||
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: they decide the kind. ──
|
||
let mut indices = Vec::with_capacity(CELLS * CELLS * 6);
|
||
let mut wet = vec![false; CELLS * CELLS];
|
||
// The majority nibble over drawn cells is both the render kind and the sound class.
|
||
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;
|
||
// A wet cell with a sentinel or NaN corner is a data anomaly; skip it.
|
||
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;
|
||
// One gate for `wet` and the triangles, so the queries and the drawing agree.
|
||
wet[row * CELLS + col] = true;
|
||
indices.extend_from_slice(&[tl, bl, br, tl, br, tr]);
|
||
}
|
||
}
|
||
if indices.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
// The kind is the cells' nibble, not the header bits; a shipped block is one class throughout.
|
||
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)?;
|
||
|
||
let depth_v_div = match kind {
|
||
LiquidKind::Ocean => ocean_depth_v_divisor(),
|
||
_ => RIVER_DEPTH_V_SATURATION,
|
||
};
|
||
// Magma's UVs are authored and its depth unread; the rest generate UVs and ramp the depth byte.
|
||
let authored_uvs = kind == LiquidKind::Magma;
|
||
let [wx, wy, _wz] = position;
|
||
|
||
// Rows step south (−X) and columns east (−Y) like the MCVT outer grid, on the terrain's
|
||
// lattice; Z is the MCLQ absolute height.
|
||
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;
|
||
// A dry vert's sentinel would stretch the AABB to ~1e38 and get a partly wet chunk culled;
|
||
// it takes `min_height`, 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 {
|
||
// 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]);
|
||
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`]) and rebuild [`Self::indices`];
|
||
/// the vertex grid stays whole. Applies [`Self::shared`] once the model has every group.
|
||
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.
|
||
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()
|
||
}
|
||
|
||
/// The surface height where a mesh's wet cells cover a raw WoW `(x, y)`.
|
||
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
|
||
}
|
||
|
||
/// Open-world lava is MCLQ magma: the pin, `.go xyz -7845.99 -1065.50 123.60 0` in Burning
|
||
/// Steppes (tile 33_46), stands on it.
|
||
#[test]
|
||
fn 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 pin itself is on lava, not merely the 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}"
|
||
);
|
||
|
||
// Nibble 6 is class 2 (magma) at FluidSpeed 4, the lava-flow loop.
|
||
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 ships, and the reference has no ADT queue for it.
|
||
assert!(
|
||
!meshes.iter().any(|m| m.kind == LiquidKind::Slime),
|
||
"slime never renders from an ADT"
|
||
);
|
||
}
|
||
|
||
/// Lava UVs are authored world-continuously, so a chunk's east edge repeats its neighbour's
|
||
/// west edge; reading the wrong bytes shows as a seam.
|
||
#[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 column 8 is the east one's column 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 0usize..9 {
|
||
// A vertex only has an authored coord where a block *draws* a cell it
|
||
// corners, and that is the mesh's own `wet` map — not a `[0.0, 0.0]` UV.
|
||
// The realm's lava leaves non-zero texcoords on undrawn verts, so the old
|
||
// sentinel guard let 121 meaningless pairs into the compare; gating on
|
||
// `wet` leaves 277, and every one of them agrees.
|
||
let drawn = |m: &LiquidMesh, col: usize| {
|
||
(row.saturating_sub(1)..=row.min(7))
|
||
.any(|r| m.wet.get(r * 8 + col).copied().unwrap_or(false))
|
||
};
|
||
if !(drawn(a, 7) && drawn(b, 0)) {
|
||
continue;
|
||
}
|
||
let (ua, ub) = (a.uvs[row * 9 + 8], b.uvs[row * 9]);
|
||
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_eq!(
|
||
shared, 277,
|
||
"the lava verts both neighbours draw, on Searing Gorge's full tile"
|
||
);
|
||
}
|
||
|
||
/// An MCNK carries one MCLQ block per set liquid header bit (`0x6af7a3`); a river mouth
|
||
/// carries a stream over the sea.
|
||
///
|
||
/// The tile is chosen, not inherited: the realm reships `World\Maps\`, and 1.12's tile 33_33
|
||
/// (which this test used to name) no longer has a two-block chunk at all.
|
||
/// `examples/liquid_scan.rs` walks the map and reports which tiles do — on Azeroth that is
|
||
/// seven, and 33_38 has the most at three, all Still-over-Ocean and all disjoint.
|
||
#[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_38.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_eq!(
|
||
two_block.len(),
|
||
3,
|
||
"Azeroth_33_38 carries three 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).
|
||
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})"
|
||
);
|
||
// `benilla_world`'s `liquid_at` takes the lowest of stacked surfaces, so a cell wet in
|
||
// both would read the stream as dry; this tile's blocks are authored disjoint. Four
|
||
// tiles do overlap and are kept out of here deliberately — Kalimdor 26_54 (four
|
||
// chunks), Kalimdor 41_29 and 41_30, and the realm's own Azeroth 36_51.
|
||
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"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Every liquid mesh a real tile builds, paired with the raw MCLQ block it came from.
|
||
fn tile_liquids_with_source(
|
||
chain: &mut crate::Chain,
|
||
map: &str,
|
||
tx: u32,
|
||
ty: u32,
|
||
) -> Vec<(benilla_adt::MclqChunk, 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).map(|mesh| (lq.clone(), mesh))
|
||
})
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The V assertions read the raw depth byte, never one recovered from the mesh's V, which would
|
||
/// pass under any divisor.
|
||
#[test]
|
||
fn each_liquid_kind_rides_its_own_verified_depth_divisor() {
|
||
let data = crate::wow_data_or_skip!();
|
||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||
|
||
// Shelf water whose bytes span the ocean ramp rather than pinning at 255. The tile is
|
||
// chosen by `examples/liquid_scan.rs` rather than inherited: the realm's `Azeroth_41_41`
|
||
// (1753514 bytes against 1.12's 1063900) carries no ocean at all now, and 29_54 is the
|
||
// cleanest case on the map — 13210 depth bytes inside the ramp and not one pinned at 255,
|
||
// so a wrong divisor cannot hide behind saturation.
|
||
let ocean: Vec<(benilla_adt::MclqChunk, LiquidMesh)> =
|
||
tile_liquids_with_source(&mut chain, "Azeroth", 29, 54)
|
||
.into_iter()
|
||
.filter(|(_, m)| m.kind == LiquidKind::Ocean)
|
||
.collect();
|
||
assert!(!ocean.is_empty(), "Azeroth_29_54 carries ocean");
|
||
|
||
let mut raw: Vec<u8> = Vec::new();
|
||
for (src, mesh) in &ocean {
|
||
for (n, v) in mesh.depths.iter().enumerate() {
|
||
let byte = src.vertices[n].depth_byte();
|
||
raw.push(byte);
|
||
assert!(
|
||
(*v - f32::from(byte) / OCEAN_DEPTH_V_SATURATION).abs() < 1e-4,
|
||
"ocean V is the raw byte over 255 (VERIFIED `c7fcd8`); byte {byte} gave V={v}"
|
||
);
|
||
}
|
||
}
|
||
|
||
// Under `/42` every one of these mid-band verts would already read fully deep.
|
||
let mid = raw.iter().filter(|&&b| (42..=200).contains(&b)).count();
|
||
let pinned = raw.iter().filter(|&&b| b == u8::MAX).count();
|
||
assert!(
|
||
mid > 100,
|
||
"this tile must carry a real shore ramp to test, saw {mid} mid-band verts"
|
||
);
|
||
assert!(
|
||
mid * 4 > pinned,
|
||
"the `/42` divisor would flatten a shore band this tile spends {mid} verts ramping \
|
||
through (against {pinned} genuinely pinned-deep) — the look change the ocean ramp is not"
|
||
);
|
||
|
||
// Control: Elwynn's rivers ride `/42`, and their channel middles saturate.
|
||
let river: Vec<(benilla_adt::MclqChunk, LiquidMesh)> =
|
||
tile_liquids_with_source(&mut chain, "Azeroth", 32, 48)
|
||
.into_iter()
|
||
.filter(|(_, m)| m.kind == LiquidKind::Still)
|
||
.collect();
|
||
assert!(!river.is_empty(), "Azeroth_32_48 carries river/lake water");
|
||
let mut saturated_below_255 = false;
|
||
for (src, mesh) in &river {
|
||
for (n, v) in mesh.depths.iter().enumerate() {
|
||
let byte = src.vertices[n].depth_byte();
|
||
let want = (f32::from(byte) / RIVER_DEPTH_V_SATURATION).clamp(0.0, 1.0);
|
||
assert!(
|
||
(*v - want).abs() < 1e-4,
|
||
"river V is clamp(byte/42) (VERIFIED `c81768`); byte {byte} gave V={v}"
|
||
);
|
||
// A byte well under 255 reading fully deep, which `/255` could never produce.
|
||
saturated_below_255 |= (42..200).contains(&byte) && *v >= 0.999;
|
||
}
|
||
}
|
||
assert!(
|
||
saturated_below_255,
|
||
"a river channel middle saturates to the deep row by byte 42, not byte 255"
|
||
);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|
||
}
|
||
|
||
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");
|
||
// Even unreferenced verts stay finite, or the AABB blows up and the chunk is culled.
|
||
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");
|
||
// Elwynn's water sits between 50 and 300 yd (Crystal Lake ≈ 144).
|
||
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"
|
||
);
|
||
}
|
||
// A chunk's surface is near-planar; a stream slopes a little across 33 yd.
|
||
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})"
|
||
);
|
||
}
|
||
}
|
||
}
|