Files
benilla-octo/crates/benilla-formats/examples/clutter_cut.rs
T
SamandClaude Fable 5.1 c3555b4eea the cooldown goes stock, early-Z comes back, and lists reach the end
- a size-less `<Model>` pane's rect is its file's box in layout units: the reference's geometry overrides (`0x76d080`/`0x76d0d0`) answer the bbox extent whenever no size is authored, `bboxExtent · 768·√(a²+1) · layoutScale` FrameXML units (`1280 · extent` at 4:3), written into the pane's layout input when the file's facts land, re-derived on an aspect change, and ended for good by any authored size; `SetPosition` is the same unit, which is how `0x4a7b20` centres the arrow at `½·GetWidth, ½·GetHeight` through the override — so the map arrow's sprite arm and its hand-sized 33.6 px footprint go (its rect is `0.0262 × 0.0263` layout units, 33.5 × 33.7 FrameXML units at 4:3, 41.0 × 41.2 at 16:9), and the stock `ItemAnim` pane plays the item push unadapted (`ReplaceIconTexture` on the type-14 batch, the 1000 ms clamp, the stock `OnAnimFinished`), `ContainerFrameAdapters.xml` losing 0887's Lua card and its hand-measured 42.41 × 124.72 rect — the implicit one at 16:9 — and keeping the keyring fork and the three bag verbs (2015); and the cooldown is the reference's own model: the texture-transform law `uv' = R_q((uv + t − p) ⊙ s) + p`, `p = (½, ½)`, the raw quaternion component-lerped and never normalised, is one function (`uv_transform`) with the shader's UV fold as its twin — rotation and scale tracks bake per file sequence slot beside the translation, the mat-anim table gains an affine row `[cos − 1, sin, sx − 1, sy − 1]` on `anim_slots.z` whose identity is the zero row so every static batch reads exactly what it did, and a UI tile clones the twin material per animated batch and writes both rows every frame off the pane's play head, never the world clock; the stock `Cooldown.xml` runs off the chain on 2007's two handlers, the file's four quadrant quads turned by their own rotation tracks (`0 → −90°` over each quadrant's 250 ms window, clockwise from 12 o'clock) and its alpha tracks revealing the star for the flash; retired: the native `Cooldown` widget and `SetCooldown` (an Era API 1.12 never had — `CreateFrame("Cooldown")` is the reference's unknown-type refusal now), the loader's `Model → Cooldown` substitution, the app's hand-built pie and star, and our `Cooldown.xml`; `assets/ui` is eight files (2019)
- `UpdateMapHighlight` gets its zone-level exits (B360): at zone zoom ours returned the nil tail for every cursor position, so hovering Deadwind Pass from Duskwood showed no label where 1.12 shows "Deadwind Pass"; the reference's `0x4a7fa0` runs two lookups at zone level and answers a NAME only — first the current zone's exploration-gated overlay list (the builder at `0x4a6ad9` gating on the explored byte), each row's HitRect scaled by 1/1002 and 1/668 against the normalized cursor, edges inclusive, the first hit naming its Area1, then the continent grid re-windowed through the displayed zone's own rect, naming the neighbouring zone or city and nothing for the displayed zone itself — and only the name-only tail is reachable from there, fileName nil and the six coords zero, so the stock frame hides the highlight quad, as the director's own 1.12 shot shows; and `SetVerticalScroll` stores the offset as given (B370, B363): the reference's `0x786db0` compares the new offset against the old and writes it to `[+0x328]` verbatim, `0x787100` re-anchors the scroll child off it, and the range at `[+0x320]` is never read on that path — every clamp the reference exhibits is FrameXML's, through the scroll bar's `[min, max]`; ours clamped into `[0, GetVerticalScrollRange()]`, the child's overflow past the FRAME, which is 40 px short of where `FauxScrollFrame_Update` sends the bar on the 220-tall skills list (three rows lost) and 15 px short on the 287-tall who list (one); the setter fires `OnVerticalScroll` only on a change, as the bytes do, `BenillaScroll_Step` bounds its wheel in Lua where the reference's handler does, and the ScrollFrame kind's prose stops calling its offset unpinned (2017)
- overdraw's cheap half, the sky first: since 0588 every sky element wrote `frag_depth = 0.0`, which costs a pipeline its early-Z, so on an outdoor frame every full-screen dome fragment under the ground and the buildings was shaded in full (the gradient's `asin`, `atan2` and eight table lookups at dawn and dusk) and thrown away; one shared `sky_vertex.wgsl` — bevy's mesh vertex verbatim plus `out.position.z = 0.0` — is now the vertex stage of the gradient dome, the cloud dome, the star patches and the celestial discs and glares, and the model lane's `WOW_SKY_DEPTH` branch moves from its fragment to its vertex, so no sky fragment declares `frag_depth` and the rasterizer rejects the covered screen before the shader runs; every sky `specialize` zeroes the rasterizer bias constant the `sky_order` rung had been leaking into `DepthBiasState::constant`, harmless only while the fragment overwrote its depth, and the prose beside the pin follows it; then the retained `static_gx` pass draws BEFORE bevy's opaque pass (`StartMainPass → static_gx → MainOpaquePass`) — buildings and doodads are the frame's best occluders and a terrain fragment its dearest, six samples a pixel — so walls reject the terrain behind them instead of the reverse, the pass taking the first-use clear with it and the dev depth probe ordered after it; measured on 16 capture scenarios: 14 bit-identical, `northshire-dusk` 1 px and `water-night` 11 px, attributed to exact depth ties on static/terrain silhouettes and last-bit interpolation noise; a depth prepass and near-to-far bins are declined until a journal says where the time goes (2016)
- the grass cut goes per pixel because of a quarter-mip: `0x6813d0` sets `D3DSAMP_MIPMAPLODBIAS = +0.25` on stage 0 (`0x6813f4`) once per frame before the detail-doodad pass — capture-confirmed on all 29 doodad batches, and outside the `[0x6b1000, 0x6b3200)` scan 2004 had called complete; it decides the whole look because the `clutter_cut` probe finds 6 of 31 shipped detail atlases carry a BINARY alpha pyramid (mip 0 has DXT3's 16 levels, every level below exactly two, both starting zones among them), so `a × ramp` degenerates to a global on/off and every texel of a leaf crosses at the same depth — Elwynn's survivor coverage is flat at 47.6 % across the whole 52.5–61.1 yd band, then 0.0 %, while the probe's box-filtered control erodes 47.7 → 20.9 over the same band, so the art has the gradient the shipped pyramid threw away, and `clutter_size` puts the median tuft at 0.92 yd tall, 1.03 yd deep, a full yard for the boundary plane to sweep; the escape is bilinear: full alpha needs a 2×2 all-opaque texel neighbourhood, and those run out between mip 2 and 3 (179/56/10/0/0/0 on the dominant Elwynn cell), so the reference's λ ≈ 2.9–3.3 sits past the cliff and the tuft erodes continuously — 2.2 % of a tuft's pixels leave in the last half-yard with the bias, 11.6 % without, 24.2 % one mip sharper; wgpu has no sampler LOD bias, so the clutter lane re-samples base colour at `view.mip_bias + 0.25`, keeping the sampler every other model batch shares at the reference's 0; ruled out on the way: our BLP decoder (all nine uploaded levels byte-identical against the archive's 52 793 BLP2s), mip regeneration, the `has_mipmaps` byte's value 2 (a plain boolean, an authoring marker), anisotropy and geometry; what it does not fix is resolution-dependent, and so is the reference's own look — λ with the bias is 3.22 at the capture's 648p but 2.48 at 1080p and 1.48 at 2160p, so at a modern resolution the reference itself pops leaf by leaf, and scaling the bias with the viewport is one reversible line left for a look call (2021)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:35:31 +07:00

220 lines
9.8 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].chunks_exact(4).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.chunks_exact(4).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(())
}