- 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>
220 lines
9.8 KiB
Rust
220 lines
9.8 KiB
Rust
//! **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(())
|
||
}
|