- the grass fade is a plane across the view, not a ring around the player: `0x6b2b80` texgens the camera-space position through `T(0,0,−52.5)·Ry(π/2)·S(1/17.5)`, so the fade coordinate is view-space depth, `u = (z_eye − 52.5)/17.5` — we measured `distance(eye, fragment)`, a sphere, which meets the ground as a circle centred on the player that slides as they walk and bites a piece out of every tuft it crosses, and that was the hard border; the ramp is `0x6b2320`'s quantised 64×8 table, `alpha = 4·(63 − col)`, read bilinearly as `(254 − 256·u)/255` and capped at `252/255` — plateau to 52.64 yd, zero at 69.86 yd, near grass 98.8 % opaque and never 100 — where we had a clean 1 → 0 line; the product `tex0.a × ramp` is what the 128/255 `detailDoodadAlpha` cutout tests (both stages MODULATE, `[0xc7b2fc]`, confirmed verbatim in all four apitrace captures), so the tuft eroding out at 61.11 yd is the reference's own behaviour and the "fix" that would have removed it never landed; the pass owns its render state — blend mode 2, ALPHAREF 128, cull off, depth-write on, the per-slot draw `0x6b2d60` binding nothing but the texture — where we passed the batch's authored blend and cull through, and the `clutter_state` probe counted the cost: of 322 detail batches, 2 author Opaque (no cutout, their margin drawn as a solid card) and 5 single-sided; the build gate measures the chunk's box against the frustum corner ray derived from the live projection (≈1.31× at 16:9, ≈1.45× at 21:9), not its centre plus a 24 yd margin that popped whole chunks in on relief (2004) - a `<Model>` widget's animation state is Lua-observable and lives in the engine: a private scene clock its own `OnUpdate` advances by `trunc(elapsed·1000)` ms on visible frames only (`0x76d7f0`), an arm with an anchor the sampler re-reads every frame (`0x7121a0`), `OnUpdateModel` at the top of every paint (`0x76d1a0`) and `OnAnimFinished` from the completion callback (`0x76cdc0`); the file's sequences and bounds are facts the host hands over, `SetModel` seeds Stand now or when they land (`0x70ebd0`) with a queued arm replaying after the seed, and the XML `scale=` on a model pane is the model's scale (`CSimpleModel::LoadXML` `0x76cac0`), which the loader had never read (2007); and the pane renders its M2 — every visible pane with a file gets a cell of one render-target atlas at its device size through one orthographic camera, the root at `1280·modelScale·layoutScale` FrameXML units per model unit, the tile's `AnimationPlayer` paused and seeked to the engine's play head, the light black, particles in their own unit (`768·√(a²+1)` per model unit, neither scale), composited as one premultiplied quad at the callback rank; two corrections off the bytes: `OnUpdateModel` fires only with a model installed (`[widget+0x318] ≠ 0`) and `OnAnimFinished` fires once per arm for a looping sequence too (`0x719370`); the loader now applies a `<Model>`'s `file=` at all, which every XML-declared pane had lacked; retired: the minimap ping's byte-measured sprite, since `MiniMapPing` and `WorldMapPing` draw their own file on the pane's clock (2013) - the stance feed fires `UPDATE_SHAPESHIFT_FORMS` only for a list change: the reference fires it at `0x4b28ff`/`0x4b2e43` from learn, unlearn and rank-replace alone, while ours fired on any change of the pushed view, and every fire runs the stock `ShapeshiftBar_Update`, which shows `ShapeshiftBarMiddle` unconditionally past two forms and `Show()`s an already-shown frame — no `OnShow`, no manage pass — so a stance switch or the category-47 cooldown arming and expiring left the middle strip drawn alone over a raised bottom-left bar; castability now rides `SPELL_UPDATE_USABLE` (`0x4b31c0`), active/texture/cooldown are pushed silently and repainted on `PLAYER_AURAS_CHANGED` and `SPELL_UPDATE_COOLDOWN`, the feed ordered before both handler sets so they read the fresh list (2009); and the loot-roll frame waits for its item template (B371): `GroupLootFrame_OnShow` paints once and nothing repaints it, and we fired `START_LOOT_ROLL` on the frame the packet arrived, against a snapshot whose name, texture and quality were still `None` — the reference's `0x61b310` ends in the item-template cache lookup `0x55ba30`, a hit firing the event there and a miss firing it from the arrival callback `0x61b460`, so the feed holds a fresh roll's event until the template answers, with two releases that are ours: a negative answer (a frame that never opens cannot even be passed on) and a 2 s deadline paid out of the roll's minute; `arg2` stays the wire's countdown, as `0x61b430` pushes it (2010) - the FPS journal ships to players: it had registered inside the dev-only `DevToolsPlugin` and been armed by an environment variable, so nothing we shipped could have written a row on the one GPU-bound frame in the corpus, a Steam Deck's at 94 % busy; `/console fpsJournal 1` — a CVar of our own, persisted like every CVar — opens `benilla-config/Diagnostics/fps-journal.csv` and appends one row a second, `0` closes it mid-run, and `WOW_FPS_JOURNAL=<path>` stays as the harness lever; eight GPU columns appended, `gpu_ms` then `gpu_opaque, gpu_static, gpu_transp, gpu_glow, gpu_post, gpu_ui, gpu_other`, read off bevy's `RenderDiagnosticsPlugin`, which times passes where the device offers `TIMESTAMP_QUERY_INSIDE_PASSES` (Vulkan and DX12) and leaves the cells empty on an Apple GPU, empty being the honest value; `static_gx`, the four `ffx_glow_*` passes and `ui_gamma_decode` open diagnostic spans so a city's own draw is named in the split; a fresh file opens with the adapter, backend, driver and `gpu_spans yes|no` line; and a probe's `/console` line, which skips the stock `ChatEdit_ParseText` and had landed in a parser arm that knew only `reloadUI`, now forwards to `ConsoleExec` and writes a CVar the way the typed line does (2008) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
160 lines
6.5 KiB
Rust
160 lines
6.5 KiB
Rust
//! Dump the **render state and texture alpha profile** of every ground-clutter (detail-doodad) model
|
|
//! the `GroundEffect*` catalog resolves — the inputs benilla's clutter draw derives its material from.
|
|
//!
|
|
//! `cargo run -p benilla-formats --example clutter_state`
|
|
//!
|
|
//! Two questions it answers, both load-bearing for the ~70 yd distance fade:
|
|
//! * what **blend mode / two-sidedness** each detail M2's batches author — the reference's
|
|
//! detail-doodad pass (`0x6b2b80`) ignores these and forces one state for every tuft, so a
|
|
//! per-batch value benilla passes through is a divergence to see, not to assume;
|
|
//! * how each detail texture's **alpha is distributed per mip level** — a cutout whose alpha is
|
|
//! binary (0/255) snaps out of an alpha test all at once, while a smoothly-distributed one
|
|
//! erodes gradually. The fade multiplies this alpha, so the distribution *is* the fade's shape.
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
fn main() -> anyhow::Result<()> {
|
|
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 model the catalog can place. `GroundEffectCatalog` exposes rows by id only, so
|
|
// sweep the id space the DBC uses (ids are small and dense; a miss costs one hash lookup).
|
|
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));
|
|
}
|
|
}
|
|
println!(
|
|
"{} effect rows, {} distinct detail models",
|
|
catalog.len(),
|
|
models.len()
|
|
);
|
|
|
|
let mut textures: BTreeMap<String, ()> = BTreeMap::new();
|
|
let mut blends: BTreeMap<String, usize> = BTreeMap::new();
|
|
let mut two_sided = (0usize, 0usize);
|
|
let mut densities: Vec<f32> = Vec::new();
|
|
for path in &models {
|
|
let Ok(bytes) = chain.read_file(path) else {
|
|
println!(" MISSING {path}");
|
|
continue;
|
|
};
|
|
let subs = match benilla_formats::parse_m2_render_submeshes(&bytes, "", &[]) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
println!(" UNPARSED {path}: {e}");
|
|
continue;
|
|
}
|
|
};
|
|
for s in &subs {
|
|
*blends.entry(format!("{:?}", s.blend)).or_default() += 1;
|
|
if s.two_sided {
|
|
two_sided.0 += 1;
|
|
} else {
|
|
two_sided.1 += 1;
|
|
}
|
|
if let Some(t) = s.texture.as_deref() {
|
|
textures.insert(t.to_string(), ());
|
|
}
|
|
if let Some(dens) = texel_density(s, 256.0) {
|
|
densities.push(dens);
|
|
}
|
|
println!(
|
|
" {path}: blend={:?} two_sided={} wrap=({},{}) tex={}",
|
|
s.blend,
|
|
s.two_sided,
|
|
s.wrap_x,
|
|
s.wrap_y,
|
|
s.texture.as_deref().unwrap_or("<none>")
|
|
);
|
|
}
|
|
}
|
|
println!("\nbatch blend modes: {blends:?}");
|
|
// Screen-space mip level per distance: the vertical FOV benilla's camera uses (Bevy's
|
|
// `PerspectiveProjection` default, ~= the reference's 44.1 deg) over a 1080-tall viewport.
|
|
let fov: f32 = std::f32::consts::PI / 4.0;
|
|
let px_per_yard = |d: f32| (1080.0 / 2.0) / ((fov / 2.0).tan() * d);
|
|
densities.sort_by(f32::total_cmp);
|
|
if !densities.is_empty() {
|
|
let med = densities[densities.len() / 2];
|
|
println!(
|
|
"\ntexel density over {} batches: min {:.1} / median {med:.1} / max {:.1} texels-per-yard",
|
|
densities.len(),
|
|
densities[0],
|
|
densities[densities.len() - 1]
|
|
);
|
|
for d in [10.0f32, 20.0, 30.0, 40.0, 52.5, 61.2, 70.0] {
|
|
let lod = (med / px_per_yard(d)).log2().max(0.0);
|
|
println!(
|
|
" d={d:5.1} yd: {:6.1} px/yd -> mip {lod:.2}",
|
|
px_per_yard(d)
|
|
);
|
|
}
|
|
}
|
|
println!("two_sided: {} yes / {} no", two_sided.0, two_sided.1);
|
|
|
|
println!("\n=== texture alpha per mip (share of texels in each band) ===");
|
|
for tex in textures.keys() {
|
|
let chainx = match benilla_formats::read_texture_mip_chain(&mut chain, tex) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
println!(" {tex}: unreadable ({e})");
|
|
continue;
|
|
}
|
|
};
|
|
if !chainx.is_rgba8() {
|
|
println!(" {tex}: block-compressed levels (not CPU-readable here)");
|
|
continue;
|
|
}
|
|
println!(
|
|
" {tex} ({}x{}, {} mips)",
|
|
chainx.width,
|
|
chainx.height,
|
|
chainx.mips.len()
|
|
);
|
|
for (i, mip) in chainx.mips.iter().enumerate() {
|
|
let (w, h) = chainx.mip_size(i as u32);
|
|
let n = mip.len() / 4;
|
|
let (mut zero, mut low, mut mid, mut high, mut full) = (0u32, 0u32, 0u32, 0u32, 0u32);
|
|
for px in mip.chunks_exact(4) {
|
|
match px[3] {
|
|
0 => zero += 1,
|
|
1..=63 => low += 1,
|
|
64..=191 => mid += 1,
|
|
192..=254 => high += 1,
|
|
255 => full += 1,
|
|
}
|
|
}
|
|
let p = |v: u32| 100.0 * f64::from(v) / n.max(1) as f64;
|
|
println!(
|
|
" mip{i} {w:3}x{h:3}: a=0 {:5.1}% 1-63 {:5.1}% 64-191 {:5.1}% 192-254 {:5.1}% 255 {:5.1}%",
|
|
p(zero), p(low), p(mid), p(high), p(full)
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Texels-per-yard for a batch: the atlas footprint its UVs cover divided by the world size its
|
|
/// geometry covers. With the screen-space pixels-per-yard at a distance this gives the mip level the
|
|
/// GPU selects there — and the mip level decides the alpha the cutout test sees.
|
|
fn texel_density(sub: &benilla_formats::RenderSubmesh, atlas: f32) -> Option<f32> {
|
|
if sub.positions.is_empty() || sub.uvs.len() != sub.positions.len() {
|
|
return None;
|
|
}
|
|
let span = |it: &mut dyn Iterator<Item = f32>| {
|
|
let (mut lo, mut hi) = (f32::MAX, f32::MIN);
|
|
for v in it {
|
|
lo = lo.min(v);
|
|
hi = hi.max(v);
|
|
}
|
|
hi - lo
|
|
};
|
|
// The tuft's tallest axis in world space (WoW +Z is up) against the same axis in UV (V).
|
|
let world = span(&mut sub.positions.iter().map(|p| p[2]));
|
|
let uv = span(&mut sub.uvs.iter().map(|t| t[1]));
|
|
(world > 1e-3).then(|| uv * atlas / world)
|
|
}
|