Files
benilla-octo/crates/benilla-formats/examples/clutter_cut.rs
T
SamandClaude Fable 5.1 60138f5d5d every objective line shows, the detail follows the row, and 1.98 lints
- a quest objective that carries both a creature and an item is two lines in the quest log, as the reference's four passes enumerate it: Graverobbers shows its Embalming Ichor (2249)
- the quest log's detail follows the live selection inside a frame, so a quest addon's log walk reads the right quest; Questie no longer files a chain quest under its shortest sibling (2247)
- a UI tint change is not a material change: a run's colour rides the batch's instance tag, so the PlayerFrame glow no longer re-prepares a material every frame (2245)
- the workspace lints clean on rustc 1.98: constant-size chunks become as_chunks, one slice fill, two unused globs
- WOW_HOVER_PROBE aims the pick in a headless run, and the inspect card names the tooltip's ladder stage (2248, 2250)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 17:01:27 +07:00

225 lines
9.9 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.
//! **How the ground-clutter fade boundary cuts** — pixel by pixel, or leaf by leaf?
//!
//! `cargo run -p benilla-formats --example clutter_cut [substring]`
//!
//! The detail-doodad draw alpha-tests `texel.a × ramp(view_depth)` against `detailDoodadAlpha`
//! (128/255), so as the ramp falls the surviving set is `{ texel.a ≥ 0.502 / ramp }`. Whether that
//! reads as a tuft *eroding* — the boundary sweeping through a leaf a pixel at a time — or as the
//! leaf *flipping whole* is decided entirely by the alpha distribution **at the mip the tuft is
//! sampled at**. The alpha arithmetic is settled and identical either way, so when the cut looks
//! wrong the answer is in what is being cut, not in the cutting.
//!
//! Per atlas, per mip level, three numbers:
//!
//! * **`distinct`** — how many distinct alpha values the level carries. **`2` means binary**: the
//! product `a × ramp` is then either `0` or `ramp`, so the alpha test degenerates to a global
//! on/off and *every texel of every leaf crosses the threshold at the same view depth*. No
//! erosion is possible at such a level, at any distance, ever.
//! * **coverage** across the fade band — the share of the atlas still drawn at each depth. Sliding
//! down = the tuft erodes. Holding flat then dropping to zero = the tuft pops.
//! * **cliff** — how much of the still-drawn coverage goes in the last 0.2 yd before the crossing.
//! 100% means everything that was left leaves in one step.
//!
//! The header line also prints the BLP's own `compression / alpha_bits / alpha_type / has_mips`,
//! because the shipped art is not uniform: some detail atlases carry a properly averaged mip
//! pyramid and some carry a **binary-thresholded** one, and the two behave completely differently
//! at the boundary. Parsed inline from the documented BLP2 layout rather than through the decoder,
//! so this reports what the *file* says independently of how we read it.
use std::collections::BTreeSet;
/// The reference's ramp (wow-re `terrain/scratch/detail-doodad-distance-fade.md`): a 64-texel
/// CLAMP/LINEAR table read at texel centres, capped at texel 0's `252/255`.
fn ramp(view_depth: f32, far: f32) -> f32 {
let near = far * 0.75;
let u = (view_depth - near) / (far - near);
((254.0 - 256.0 * u) / 255.0).clamp(0.0, 252.0 / 255.0)
}
/// The detail-doodad alpha-test reference (`detailDoodadAlpha` = 128).
const CUTOUT: f32 = 128.0 / 255.0;
/// Where in the band to sample coverage — clustered at the 61.11 yd crossing, where even a fully
/// opaque texel fails the test and the whole question lives.
const BANDS: [f32; 8] = [52.5, 54.0, 56.0, 58.0, 60.0, 61.0, 61.1, 61.2];
/// What the BLP2 header says about itself: `compression` (1 = palettized, 2 = DXT, 3 = BGRA8),
/// `alpha_bits`, `alpha_type` (DXT sub-form: 1 = DXT3, 7 = DXT5) and the `has_mipmaps` byte at
/// offset 0x0B — whose value tracks, in the shipped detail art, whether the mip chain is averaged
/// or thresholded.
fn blp_header(bytes: &[u8]) -> Option<(u8, u8, u8, u8)> {
(bytes.len() >= 20 && &bytes[0..4] == b"BLP2")
.then(|| (bytes[8], bytes[9], bytes[10], bytes[11]))
}
fn main() -> anyhow::Result<()> {
let want = std::env::args().nth(1).unwrap_or_default().to_uppercase();
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
let mut chain = benilla_formats::open_chain(&data)?;
let catalog = benilla_formats::load_ground_effect_catalog(&mut chain, true)?;
// Every distinct detail texture the catalog can place.
let mut models: BTreeSet<String> = BTreeSet::new();
for id in 0..4096u32 {
if let Some(e) = catalog.effect(id) {
models.extend(e.models().into_iter().map(str::to_string));
}
}
let mut textures: BTreeSet<String> = BTreeSet::new();
for path in &models {
let Ok(bytes) = chain.read_file(path) else {
continue;
};
let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, "", &[]) else {
continue;
};
for s in subs {
if let Some(t) = s.texture {
textures.insert(t.to_uppercase());
}
}
}
println!("cutout {CUTOUT:.4} (detailDoodadAlpha 128); view depth → ramp:");
for d in BANDS {
println!(" {d:5.1} yd ramp {:.4}", ramp(d, 70.0));
}
println!("\ncoverage% drawn at each of those depths; `distinct 2` = binary = cannot erode");
let (mut binary_atlases, mut graded_atlases) = (Vec::new(), Vec::new());
for tex in textures.iter().filter(|t| t.contains(&want)) {
let header = chain.read_file(tex).ok().and_then(|b| blp_header(&b));
let Ok(mips) = benilla_formats::read_texture_mip_chain(&mut chain, tex) else {
continue;
};
if !mips.is_rgba8() {
continue; // block-compressed levels are not CPU-readable here
}
let name = tex.rsplit('\\').next().unwrap_or(tex);
match header {
Some((c, ab, at, hm)) => {
println!("\n{name} compression={c} alpha_bits={ab} alpha_type={at} has_mips={hm}")
}
None => println!("\n{name}"),
}
// What an AVERAGED chain would carry, for comparison — mip0's alpha box-filtered down.
// This is the control for a binary chain: it says how much of the flat coverage is the
// art's own and how much is the authored mip pyramid throwing the gradient away.
let mip0_alpha: Vec<u8> = mips.mips[0]
.as_chunks::<4>()
.0
.iter()
.map(|p| p[3])
.collect();
let mut averaged: Vec<Vec<u8>> = vec![mip0_alpha];
for i in 1..5usize {
let (pw, ph) = mips.mip_size(i as u32 - 1);
let (w, h) = mips.mip_size(i as u32);
let prev = &averaged[i - 1];
if prev.len() != (pw * ph) as usize {
break;
}
let mut next = Vec::with_capacity((w * h) as usize);
for y in 0..h {
for x in 0..w {
let sample = |dx: u32, dy: u32| {
let (sx, sy) = ((2 * x + dx).min(pw - 1), (2 * y + dy).min(ph - 1));
u32::from(prev[(sy * pw + sx) as usize])
};
let sum = sample(0, 0) + sample(1, 0) + sample(0, 1) + sample(1, 1);
next.push((sum / 4) as u8);
}
}
averaged.push(next);
}
// Mips 0..4 span roughly 10 yd to 90 yd of viewing distance; 2-3 is the fade band itself.
let mut binary_below_zero = false;
for (i, mip) in mips.mips.iter().enumerate().take(5) {
let (w, h) = mips.mip_size(i as u32);
let alpha: Vec<u8> = mip.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
if alpha.len() != (w * h) as usize {
println!(
" mip{i}: SIZE MISMATCH — {} texels for {w}x{h}",
alpha.len()
);
continue;
}
let mut seen = [false; 256];
for &v in &alpha {
seen[v as usize] = true;
}
let distinct = seen.iter().filter(|&&s| s).count();
if i > 0 && distinct <= 2 {
binary_below_zero = true;
}
let drawn = |a: u8, r: f32| f32::from(a) / 255.0 * r >= CUTOUT;
let coverage = |r: f32| {
alpha.iter().filter(|&&a| drawn(a, r)).count() as f64 / alpha.len() as f64 * 100.0
};
let before = coverage(ramp(61.0, 70.0));
let after = coverage(ramp(61.2, 70.0));
let cliff = if before > 0.0 {
100.0 * (before - after) / before
} else {
0.0
};
let covs: Vec<String> = BANDS
.iter()
.map(|d| format!("{:5.1}", coverage(ramp(*d, 70.0))))
.collect();
println!(
" mip{i} {w:3}x{h:3} distinct {distinct:<4} [{}] cliff@61 {cliff:3.0}%",
covs.join(" ")
);
// The averaged control, printed only where the authored level is binary — that is the
// only case where the two can disagree, and seeing them side by side is the point.
if i > 0 && distinct <= 2 {
if let Some(avg) = averaged.get(i) {
let mut seen = [false; 256];
for &v in avg {
seen[v as usize] = true;
}
let cov_avg = |r: f32| {
avg.iter().filter(|&&a| drawn(a, r)).count() as f64 / avg.len() as f64
* 100.0
};
let b = cov_avg(ramp(61.0, 70.0));
let a2 = cov_avg(ramp(61.2, 70.0));
let covs: Vec<String> = BANDS
.iter()
.map(|d| format!("{:5.1}", cov_avg(ramp(*d, 70.0))))
.collect();
println!(
" ^ averaged distinct {:<4} [{}] cliff@61 {:3.0}%",
seen.iter().filter(|&&s| s).count(),
covs.join(" "),
if b > 0.0 { 100.0 * (b - a2) / b } else { 0.0 }
);
}
}
}
if binary_below_zero {
binary_atlases.push(name.to_string());
} else {
graded_atlases.push(name.to_string());
}
}
println!(
"\n{} atlases carry a BINARY mip chain below level 0 — every leaf on them flips whole at \
the crossing, at any distance:",
binary_atlases.len()
);
for a in &binary_atlases {
println!(" {a}");
}
println!(
"\n{} carry a graded chain and erode normally.",
graded_atlases.len()
);
Ok(())
}