Files
benilla-octo/crates/benilla-formats/examples/water_here.rs
T
SamandClaude Fable 5.1 9d2ffd71d8 the foam stops at the waterline, and skill bonuses show the right halves
- the foam still washed past the waterline onto the sand after the shore fix: the slope-scaled depth bias ported from the reference's `glPolygonOffset(-4.0, …)` pulls toward the eye as the square of view distance, and what it spends is the wet-cell skirt — whole cells are the unit of liquid geometry, so a shoreline carries liquid-lattice triangles over dry sand (23 cells, up to 7.1 yd inland at the reported beach) held back by nothing but the terrain having drawn first; the tie it would settle does not exist here (our foam patch IS the liquid surface's own triangles — same cells, same winding, identity transform — so the depths agree exactly), and −4.0 is a hardcoded immediate every consumer of that state gets, not the foam lane's number; the slope term is disarmed with an `== 0.0` assert, reproduced three ways at the director's pin (slope 0 stops dead at the waterline, 4.0 washes onto the sand, 64.0 does it wide)
- weapon and defense skill bonuses showed the halves the wrong way round: the reader every skill-shaped binding goes through (`0x5ea460`) writes `value + permBonus` and `tempBonus`; ours returned `(value, temp + perm)` — right total, wrong split, which is why no fixture caught it — so a 300 weapon skill with a +5 talent and a +10 aura read 300 (+15) where the reference prints 305 (+10), on the sheet's Attack row and in the Defense tooltip's `(base +x)`; `UnitRangedAttack` is also pinned as a direct call gated on the PLAYER typemask alone, so a pet gets `(0, 0)`
- instruments: `water_here` also reports WET cells the ground climbs out of (the skirt a depth bias spends), the foam wake can be laid along a bank (`WOW_WFX_HEAD`) since no shoreline in the game is axis-aligned, `fxview` can stand its subject at any world point (`WOW_FX_AT`) so a blob shadow can be photographed on any terrain, and `WOW_MAP` refuses a name instead of silently falling through to Eastern Kingdoms — `WOW_MAP=Kalimdor` had produced a subject floating in empty ocean that read as a renderer bug

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:03:14 +07:00

382 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! What the ADT liquid actually **is** under a pin — the shoreline instrument:
//! `cargo run -p benilla-formats --example water_here -- <map> <x> <y> [radius_yd]`
//! e.g. `water_here Azeroth -12512.69 -180.21 40`.
//!
//! A water-at-the-shore report ("there's a step in it", "the foam runs past the waterline") is a
//! question about three surfaces that only ever agree in the middle of a lake: the MCLQ **wet-cell
//! lattice** (4.167-yd cells, the granularity everything on the water path clips to), the liquid
//! **surface height** on those cells, and the **terrain** underneath. This prints all three over one
//! neighbourhood so the geometry can be read instead of guessed:
//!
//! - the **block census** — one line per MCLQ block: kind, wet cells, surface-height span, depth-byte
//! span. Where a step would be *authored*, this is where it shows: two blocks over one chunk, or a
//! block whose own plane has relief;
//! - the **overlap** scan — cells claimed by two blocks at once, which would composite the
//! translucent water twice;
//! - the **seam** scan — every pair of adjacent wet cells whose planes disagree, so an authored step
//! can be told apart from a rendered one before anyone opens the renderer;
//! - the **lattice-vs-waterline** scan — dry cells the ground still runs under. Whole wet cells are
//! the only thing the renderer draws, so wherever the lattice runs out before the ground climbs
//! through the plane, the water ends on a 4.167-yd straight edge in open water: a shape no
//! shoreline has;
//! - its **mirror** — wet cells the ground climbs out of, which is the skirt of liquid-lattice
//! geometry that lies over dry sand. Nothing renders there only because the terrain drew first
//! and won the depth test, so this is the exact budget a depth bias spends: a decal pulled `b`
//! yards toward the eye paints `b / slope` yards of that skirt;
//! - the **shoreline slope** at the pin, and what a coplanarity *lift* costs there. A decal held `l`
//! yards above the water plane keeps painting for `l / slope` yards past the waterline, on dry
//! ground — the arithmetic behind B348's foam-on-the-sand, and the reason that settle is a depth
//! bias now and not a lift.
//!
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
use std::collections::BTreeMap;
use benilla_formats::{terrain_height_at, ChunkMesh, CHUNK_SIZE};
/// One MCLQ cell edge, in yards — the granularity of `wet`, and of every clip on the water path.
const CELL: f32 = CHUNK_SIZE / 8.0;
/// How far past the reported radius the block census still enumerates, so that a cell at the edge
/// of the answer has its real neighbours to be judged against (one chunk clears it).
const MARGIN: f32 = CHUNK_SIZE;
/// One seam between two adjacent wet cells whose surface planes disagree.
struct Seam {
/// `to.surface − from.surface`, in yards.
dz: f32,
from: (i32, i32),
to: (i32, i32),
}
/// One MCLQ cell of one block, resolved into world terms.
struct Cell {
/// Cell-centre world XY.
x: f32,
y: f32,
/// The block's surface height over the cell (bilinear at the centre, as the queries sample it).
surface: f32,
/// Terrain height at the same point, `None` where the tile has a hole.
ground: Option<f32>,
/// Which chunk/block this cell came from — `(chunk index, block index)`.
owner: (usize, usize),
kind: benilla_formats::LiquidKind,
}
fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
let usage = || anyhow::anyhow!("usage: water_here <map> <x> <y> [radius_yd]");
let map = args.next().ok_or_else(usage)?;
let x: f32 = args.next().ok_or_else(usage)?.parse()?;
let y: f32 = args.next().ok_or_else(usage)?.parse()?;
let radius: f32 = args.next().map_or(Ok(30.0), |r| r.parse())?;
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
// One tile of slack: a shoreline pin sits on a tile seam as often as not.
let tiles = benilla_formats::load_tiles_around(&mut chain, &map, x, y, 1)?;
let chunks: Vec<ChunkMesh> = tiles.into_iter().flat_map(|(_, t)| t.chunks).collect();
println!(
"{map}: pin ({x}, {y}) r={radius} yd — {} chunks loaded",
chunks.len()
);
// ── The block census, and the cells ──────────────────────────────────────────────────────
let mut cells: Vec<Cell> = Vec::new();
for (ci, chunk) in chunks.iter().enumerate() {
let Some(nw) = chunk.positions.first() else {
continue;
};
// The chunk's own footprint against the pin box (chunk spans south/east of its NW corner),
// widened by MARGIN so every cell inside the radius has its real neighbours loaded — a cell
// at the box edge would otherwise read as "the lattice stops here" when it is only the
// enumeration that stopped.
if nw[0] - CHUNK_SIZE > x + radius + MARGIN
|| nw[0] < x - radius - MARGIN
|| nw[1] - CHUNK_SIZE > y + radius + MARGIN
|| nw[1] < y - radius - MARGIN
{
continue;
}
for (bi, liq) in chunk.liquids.iter().enumerate() {
let [cols, rows] = liq.grid;
let (cols, rows) = (cols as usize, rows as usize);
let wet_n = liq.wet.iter().filter(|w| **w).count();
let (mut zlo, mut zhi) = (f32::MAX, f32::MIN);
let (mut dlo, mut dhi) = (f32::MAX, f32::MIN);
for (n, p) in liq.positions.iter().enumerate() {
let cell_touched = touching_cells(n, cols, rows)
.into_iter()
.any(|c| liq.wet.get(c).copied().unwrap_or(false));
if !cell_touched {
continue;
}
zlo = zlo.min(p[2]);
zhi = zhi.max(p[2]);
dlo = dlo.min(liq.depths[n]);
dhi = dhi.max(liq.depths[n]);
}
println!(
" chunk[{ci:3}] {:>2}_{:<2} block[{bi}] {:?} nibble {} — {wet_n:2}/{} wet, \
z {zlo:.3}..{zhi:.3} (span {:.3}), depthV {dlo:.3}..{dhi:.3}",
chunk.index_x,
chunk.index_y,
liq.kind,
liq.sound_nibble,
(cols - 1) * (rows - 1),
zhi - zlo,
);
for j in 0..rows - 1 {
for i in 0..cols - 1 {
if !liq.wet[j * (cols - 1) + i] {
continue;
}
let corners = [
liq.positions[j * cols + i],
liq.positions[j * cols + i + 1],
liq.positions[(j + 1) * cols + i],
liq.positions[(j + 1) * cols + i + 1],
];
let cx = corners.iter().map(|p| p[0]).sum::<f32>() / 4.0;
let cy = corners.iter().map(|p| p[1]).sum::<f32>() / 4.0;
let surface = corners.iter().map(|p| p[2]).sum::<f32>() / 4.0;
cells.push(Cell {
x: cx,
y: cy,
surface,
ground: terrain_height_at(&chunks, [cx, cy, 0.0]),
owner: (ci, bi),
kind: liq.kind,
});
}
}
}
}
if cells.is_empty() {
println!(" (no MCLQ liquid within the box)");
return Ok(());
}
// ── Overlap: two blocks claiming one patch of world, at two heights ──────────────────────
// Key a cell by its lattice index, floored — two DIFFERENT cells of one block must never
// collide, or the overlap report cries wolf on its own rounding.
let key = |c: &Cell| ((c.x / CELL).floor() as i32, (c.y / CELL).floor() as i32);
let mut by_key: BTreeMap<(i32, i32), Vec<usize>> = BTreeMap::new();
for (n, c) in cells.iter().enumerate() {
by_key.entry(key(c)).or_default().push(n);
}
let mut overlaps = 0;
for (k, ns) in &by_key {
if ns.len() < 2 {
continue;
}
overlaps += 1;
if overlaps <= 12 {
let list: Vec<String> = ns
.iter()
.map(|&n| {
format!(
"chunk[{}]blk[{}] {:?} z {:.3}",
cells[n].owner.0, cells[n].owner.1, cells[n].kind, cells[n].surface
)
})
.collect();
println!(" OVERLAP at cell {k:?}: {}", list.join(" | "));
}
}
println!(" overlapping cells: {overlaps}");
// ── The seam scan: adjacent wet cells whose planes disagree ──────────────────────────────
let mut steps: Vec<Seam> = Vec::new();
for (k, ns) in &by_key {
let z = cells[ns[0]].surface;
for nb in [(k.0 + 1, k.1), (k.0, k.1 + 1)] {
if let Some(other) = by_key.get(&nb) {
let dz = cells[other[0]].surface - z;
if dz.abs() > 0.01 {
steps.push(Seam {
dz,
from: *k,
to: nb,
});
}
}
}
}
steps.sort_by(|a, b| b.dz.abs().partial_cmp(&a.dz.abs()).expect("finite"));
println!(
" wet-cell seams with a height step > 0.01 yd: {} (largest first)",
steps.len()
);
for s in steps.iter().take(10) {
println!(" {:?} -> {:?} dz {:+.3} yd", s.from, s.to, s.dz);
}
// ── Where the LATTICE stops vs where the WATERLINE is ────────────────────────────────────
// The renderer draws whole wet cells and nothing else, so the water's visible boundary is the
// *lattice* edge wherever the lattice runs out before the ground climbs through the plane. That
// edge is a 4.167-yd straight line in open water — the shape a shoreline never has. Sample every
// dry cell that touches a wet one on a 0.25-yd grid and report how deep the water would have
// been there.
let mut short_cells: Vec<(f32, (i32, i32))> = Vec::new();
for k in by_key.keys().copied().collect::<Vec<_>>() {
for nb in [
(k.0 + 1, k.1),
(k.0 - 1, k.1),
(k.0, k.1 + 1),
(k.0, k.1 - 1),
] {
if by_key.contains_key(&nb) {
continue;
}
let surface = cells[by_key[&k][0]].surface;
let (bx, by) = ((nb.0 as f32 + 0.5) * CELL, (nb.1 as f32 + 0.5) * CELL);
let mut deepest = f32::MIN;
for si in 0..=16 {
for sj in 0..=16 {
let px = bx - CELL / 2.0 + CELL * si as f32 / 16.0;
let py = by - CELL / 2.0 + CELL * sj as f32 / 16.0;
if let Some(g) = terrain_height_at(&chunks, [px, py, 0.0]) {
deepest = deepest.max(surface - g);
}
}
}
if deepest > 0.02 && (bx - x).hypot(by - y) <= radius {
short_cells.push((deepest, nb));
}
}
}
short_cells.sort_by(|a, b| b.0.partial_cmp(&a.0).expect("finite"));
short_cells.dedup_by_key(|(_, k)| *k);
println!(
"\n DRY cells the ground still runs under (the lattice stops before the waterline does): {}",
short_cells.len()
);
for (d, k) in short_cells.iter().take(10) {
println!(
" cell {k:?} at ({:.1}, {:.1}) — up to {d:.3} yd of water missing",
(k.0 as f32 + 0.5) * CELL,
(k.1 as f32 + 0.5) * CELL
);
}
// ── The mirror: WET cells the ground climbs out of ───────────────────────────────────────
// The scan above finds water that is missing; this one finds water geometry that is *over dry
// land*. Whole wet cells are the unit the renderer draws, so wherever the ground climbs through
// the plane inside a cell, the liquid mesh — and every decal built on the same lattice — carries
// a skirt of geometry up the beach. Nothing renders there only because the terrain drew first
// and won the depth test, which makes this number the exact **budget a depth bias spends**: a
// decal pulled `b` yards toward the eye paints `b / slope` yards of that skirt onto the sand.
let mut skirts: Vec<(f32, f32, (i32, i32))> = Vec::new(); // (height above plane, run inland, cell)
for (k, idxs) in by_key.iter() {
let c = &cells[idxs[0]];
if (c.x - x).hypot(c.y - y) > radius {
continue;
}
let mut highest = 0.0_f32;
for si in 0..=16 {
for sj in 0..=16 {
let px = c.x - CELL / 2.0 + CELL * si as f32 / 16.0;
let py = c.y - CELL / 2.0 + CELL * sj as f32 / 16.0;
if let Some(g) = terrain_height_at(&chunks, [px, py, 0.0]) {
highest = highest.max(g - c.surface);
}
}
}
if highest > 0.02 {
let h = 1.0_f32;
let sample = |dx: f32, dy: f32| terrain_height_at(&chunks, [c.x + dx, c.y + dy, 0.0]);
let slope = match (
sample(h, 0.0),
sample(-h, 0.0),
sample(0.0, h),
sample(0.0, -h),
) {
(Some(px), Some(mx), Some(py), Some(my)) => {
(((px - mx) / (2.0 * h)).powi(2) + ((py - my) / (2.0 * h)).powi(2)).sqrt()
}
_ => 0.0,
};
let run = if slope > 0.0 {
highest / slope
} else {
f32::INFINITY
};
skirts.push((highest, run, *k));
}
}
skirts.sort_by(|a, b| b.1.partial_cmp(&a.1).expect("finite"));
println!(
"\n WET cells with dry ground inside them (the skirt the depth test has to eat): {}",
skirts.len()
);
for (hi, run, k) in skirts.iter().take(6) {
println!(
" cell {k:?} at ({:.1}, {:.1}) — ground up to {hi:.3} yd above the plane, {run:.2} yd inland",
(k.0 as f32 + 0.5) * CELL,
(k.1 as f32 + 0.5) * CELL
);
}
// ── The waterline's slope, and what a coplanarity lift costs horizontally ────────────────
// A decal on the water plane, lifted `l` yards to win the coplanar tie, keeps painting for
// `l / slope` yards past the waterline — up the beach, on dry ground. Measure the slope where
// the plane actually meets the ground nearest the pin.
let mut best: Option<(f32, f32, f32, f32)> = None; // (dist to pin, x, y, slope)
for c in &cells {
let Some(g) = c.ground else { continue };
if (c.surface - g).abs() > 0.5 {
continue;
}
let h = 1.0_f32;
let sample = |dx: f32, dy: f32| terrain_height_at(&chunks, [c.x + dx, c.y + dy, 0.0]);
let (Some(px), Some(mx), Some(py), Some(my)) = (
sample(h, 0.0),
sample(-h, 0.0),
sample(0.0, h),
sample(0.0, -h),
) else {
continue;
};
let slope = (((px - mx) / (2.0 * h)).powi(2) + ((py - my) / (2.0 * h)).powi(2)).sqrt();
let d = (c.x - x).hypot(c.y - y);
if best.as_ref().is_none_or(|b| d < b.0) {
best = Some((d, c.x, c.y, slope));
}
}
if let Some((d, wx, wy, slope)) = best {
println!(
"\n waterline nearest the pin: ({wx:.2}, {wy:.2}), {d:.1} yd away — ground slope {slope:.4} \
({:.1}%)",
slope * 100.0
);
for lift in [0.03_f32, 0.01, 0.0] {
println!(
" a {lift:.2}-yd lift paints {:.2} yd of dry beach",
if slope > 0.0 {
lift / slope
} else {
f32::INFINITY
}
);
}
}
Ok(())
}
/// The (up to four) cell indices a grid vertex `n` is a corner of, row-major over `(cols−1)` cells.
fn touching_cells(n: usize, cols: usize, rows: usize) -> Vec<usize> {
let (i, j) = (n % cols, n / cols);
let mut out = Vec::new();
for dj in [0isize, -1] {
for di in [0isize, -1] {
let (ci, cj) = (i as isize + di, j as isize + dj);
if ci >= 0 && cj >= 0 && (ci as usize) < cols - 1 && (cj as usize) < rows - 1 {
out.push(cj as usize * (cols - 1) + ci as usize);
}
}
}
out
}