the voidwalker's eyes come back, and skinning becomes ours

- The voidwalker's eye glow vanished whenever the camera rose above eye level.
  Three investigations said depth; measuring the depth buffer inside the live
  quads said otherwise — the fragments reached the framebuffer every frame, and
  the body's own transparent batches painted over them. The real client draws a
  model's particles after that model's batches, always. Ours now does: every
  effect carries a draw-order rung sized to clear exactly its own model.
- Widened to every effect family after asking "what else does this fix": ribbon
  trails (a wisp's streamers wove six batches deep into its own body and
  flickered as the head whipped) and 3-D model particles (Cone of Cold's
  shards). A census over all 9,691 models found 967 whose own body could paint
  over their effects; all take the rung now.
- The skinning lane is ours end to end: every rig's joint matrices live in our
  shared GPU buffer, written only when a rig actually moved, and our shader
  reads them. The engine's lane rewrote the entire joint buffer every frame and
  swept every entity in the world for changed transforms — that machinery now
  runs against empty sets. Another 6.7 CPU ms off the worst reported dungeon
  spot, which now holds a flat 60 fps with p99 under 19 ms.
- Getting around while developing: Ctrl+Cmd+G lands your character where the
  free-fly camera is, and Alt-clicking the world map teleports you there (both
  ride the server's own teleport, GM-gated). The frozen-body coordinates the
  panel used to copy while flying now follow the camera.
- A stuck main thread now diagnoses itself: a watchdog samples our own stacks
  and prints where the time went, and a hung teardown names its last system
  instead of hanging the window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-07-27 19:38:40 +07:00
co-authored by Claude Fable 5
parent d00912edd0
commit 90efd4efad
33 changed files with 1996 additions and 86 deletions
Generated
+1
View File
@@ -647,6 +647,7 @@ dependencies = [
"benilla-wdt",
"benilla-wmo",
"clap",
"glam 0.31.1",
"image",
]
+19 -1
View File
@@ -148,6 +148,11 @@ pub struct ModelEmitter {
/// particles render as 3-D instances of it instead of billboard quads (wow-re
/// `part-model-particles.md`). `None` when unauthored.
pub geometry: Option<Handle<M2Model>>,
/// How far the OWNER model's own transparent-pass batches sort from its origin, model-local
/// yards — the bound the draw-order rung is sized from (`particles::owner_last_bias`,
/// decisions 0719/0721). Computed by [`benilla_formats::m2_owner_reach`], which is where the
/// reasoning lives.
pub owner_reach: f32,
}
/// Whether looping `anim` would look like anything other than the **static mesh** — the doodad
@@ -204,6 +209,12 @@ pub struct ModelRibbon {
pub def: benilla_formats::RibbonEmitterDef,
pub texture: Option<Handle<Image>>,
pub bone_pivot: [f32; 3],
/// The owner model's reach — same field, same bound, same reason as
/// [`ModelEmitter::owner_reach`]. A trail is one of its model's emitters and takes the same
/// owner-last draw-order rung: measured on the wisp (14 blend batches, 3 streamers), its
/// trails otherwise interleave with its own body, 6 batches deep, and which batches are over
/// which streamer *changes frame to frame* as they whip (decision 0721).
pub owner_reach: f32,
}
/// One M2 light block of an [`M2Model`]: the raw [`M2Light`] record plus — like
@@ -240,6 +251,10 @@ impl AssetLoader for M2ModelLoader {
// skins are applied at the spawn site from CreatureDisplayInfo), so an empty dir is correct.
let subs = parse_m2_render_submeshes(&bytes, "", &[]).map_err(to_io)?;
let bounds = parse_m2_bounds(&bytes).ok();
// The owner-last draw-order bound, once for the whole model: every effect this model
// authors (quad emitters, ribbon trails, model-particle instances) takes the rung it sizes,
// because the reference draws them all in the one post-batch bracket.
let owner_reach = benilla_formats::m2_owner_reach(&subs);
// The collision hull (collide-iff-hull): keep only a non-empty mesh so the spawn site can skip
// hull-less props cheaply.
let collision = parse_m2_collision_hull(&bytes)
@@ -336,11 +351,13 @@ impl AssetLoader for M2ModelLoader {
bone_pivot,
recursion,
geometry,
owner_reach,
}
})
.collect();
// Ribbon emitters (trails): same texture resolution + bone-pivot bake as the particles.
// Ribbon emitters (trails): same texture resolution, bone-pivot bake and owner-reach bake
// as the particles — a trail is one of the model's emitters and draws under the same law.
let ribbons = benilla_formats::parse_m2_ribbon_emitters(&bytes)
.unwrap_or_default()
.into_iter()
@@ -359,6 +376,7 @@ impl AssetLoader for M2ModelLoader {
def,
texture,
bone_pivot,
owner_reach,
}
})
.collect();
+3
View File
@@ -17,3 +17,6 @@ benilla-blp = { path = "../benilla-blp" }
image = "0.25"
benilla-wdt = { path = "../benilla-wdt" }
benilla-wmo = { path = "../benilla-wmo" }
[dev-dependencies]
glam = "0.31"
@@ -0,0 +1,49 @@
//! Print every render batch's **animated material alpha** across a named sequence — the factor the
//! renderer multiplies into the instance's render alpha (`A = instanceAlpha × colourAlpha × weight`).
//!
//! `cargo run -p benilla-formats --example batch_alpha -- 'Creature\Voidwalker\Voidwalker.m2' 0`
//!
//! The number that matters is whether a batch ever reads **exactly 1.0**: benilla swaps a faded
//! entity onto its `AlphaMode::Blend` twin whenever `alpha < 1.0`, and that twin forces depth-write
//! ON. A batch whose authored weight never reaches 1.0 therefore never returns to the steady
//! material — so a `no_depth_write` batch (M2 flag 0x10) starts writing depth it must never write.
fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
let virt = args
.next()
.ok_or_else(|| anyhow::anyhow!("usage: batch_alpha <m2 path> [sequence slot]"))?;
let slot: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let mut chain = benilla_formats::open_chain(&data)?;
let bytes = chain.read_file(&virt)?;
let subs = benilla_formats::parse_m2_render_submeshes(&bytes, "", &[])?;
println!("sequence slot {slot} — {} batches", subs.len());
for (i, s) in subs.iter().enumerate() {
let Some(a) = s.alpha_anim.as_ref() else {
println!(
" batch {i:2}: no alpha_anim (static 1.0) depth_write={}",
!s.no_depth_write
);
continue;
};
let samples: Vec<f32> = (0..24u16)
.map(|k| a.sample(Some(slot), 3.0 * f32::from(k) / 24.0))
.collect();
let lo = samples.iter().copied().fold(f32::MAX, f32::min);
let hi = samples.iter().copied().fold(f32::MIN, f32::max);
let ever_one = samples.iter().any(|&v| v >= 1.0);
println!(
" batch {i:2}: alpha {lo:.4}..{hi:.4} ever==1.0: {ever_one:<5} \
depth_write={:<5} additive={:<5} {}",
!s.no_depth_write,
s.additive,
if ever_one {
""
} else {
"<-- STUCK on the blend twin"
}
);
}
Ok(())
}
+49
View File
@@ -0,0 +1,49 @@
//! `emdump <internal\path.m2>` — every particle emitter one model authors, one line each.
//!
//! The per-emitter counterpart to `benilla-extract partcensus`, which answers "which models in the
//! corpus carry emitters matching this flag mask?" but never what an individual emitter *is*. B16
//! needed the other direction — the voidwalker's eye glow is emitters 1 and 2 of four, bone 60/61,
//! flags `0x0001` (plain anchored), `Add`/`GLOWBALL.BLP` — and reading that off a hex dump by hand
//! is exactly the kind of asset fact that should be one command.
//!
//! The header line carries the model's **reach** (how far its own transparent-pass batches sort
//! from its origin — `m2_owner_reach`) and the `Transparent3d` draw-order rung it produces
//! (`owner_last_rung`): the rung is what keeps a model's effects drawing after its own transparent
//! batches, and it is sized from that number, so the two belong on screen together.
fn main() -> anyhow::Result<()> {
let virt = std::env::args().nth(1).unwrap();
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let mut chain = benilla_formats::open_chain(&data)?;
let bytes = chain.read_file(&virt)?;
let dir = virt.rsplit_once('\\').map_or("", |(d, _)| d);
let subs = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default();
// The renderer's own bound and rung, not a re-derivation of them: a survey tool has to print
// what the renderer will actually do.
let reach = benilla_formats::m2_owner_reach(&subs);
let rung = benilla_formats::owner_last_rung(reach);
println!("{virt}: reach {reach:.3} yd -> draw-order rung {rung:.0}");
for (i, e) in benilla_formats::parse_m2_particle_emitters(&bytes)?
.iter()
.enumerate()
{
println!(
"emitter {i}: bone {:>3} flags {:#06x} model_space={} pos ({:.4},{:.4},{:.4}) blend {:?} \
shape {:?} speed {:.4}±{:.4} grav {:.4} life {:.4} tex {:?}",
e.bone,
e.flags,
e.model_space(),
e.position[0],
e.position[1],
e.position[2],
e.blend,
e.shape,
e.emission_speed,
e.speed_variation,
e.gravity,
e.lifespan,
e.texture,
);
}
Ok(())
}
@@ -0,0 +1,290 @@
//! **Is the eye emitter still proud of the face once the model ANIMATES?**
//!
//! `eye_quad_pass` answers the depth contest at *bind* pose, where benilla already matches the
//! reference. Decision 0707 measured the failing case and found the split: benilla's freshest eye
//! particle — born this frame at the current bone pose — sits **1.6–4.7 cm behind the face our
//! renderer draws**, against **2.5–34 mm** at bind. "Faithful at rest" and "faithful in motion" are
//! different claims, and this is the instrument for the second one.
//!
//! It builds the bone palette ourselves, from our own parse, at sampled times across a sequence:
//! `palette[b] = parentPalette · T(pivot) · R(rot) · S(scale) · T(-pivot)`, the M2 skinning matrix.
//! Then, per sample, it reports the two numbers that decide the contest:
//!
//! - `gap` — the distance from the animated eye-bone pivot (where our particles are born, verified
//! `birth == joint`) to the **nearest skinned vertex** of the depth-writing batches. At bind this
//! is 0.0162 for bone 60 (wow-re `part-flush-emitter-depth.md` §0). If our animation swings the
//! skin centimetres past the bone, this grows — and that growth *is* the bug.
//! - `pass%` — the constant-depth quad's surviving area against the animated shell, front view.
//!
//! `cargo run -p benilla-formats --example eye_burial_anim -- 'Creature\Voidwalker\Voidwalker.m2' 0`
//!
//! A `gap` that stays near its bind value across the cycle says our pose is self-consistent and 0707's
//! centimetres came from somewhere else in the renderer. A `gap` that blows out says our skinning or
//! our animation moved the face off the bone, and the fix is there.
use benilla_formats::{ModelAnimation, RenderSubmesh, Skeleton};
use glam::{Mat4, Quat, Vec3};
/// The M2 skinning palette at time `t` (seconds into `anim`), in file-bone order.
///
/// The pivot dance is the whole point: a bone's animated rotation/scale act **about its own pivot**,
/// so the matrix is `T(pivot) · R · S · T(-pivot)` composed under the parent. At rest (no keys) every
/// factor is identity and the palette is identity — which is why an emitter record `position` that
/// equals its bone pivot maps to itself at bind.
fn palette(skel: &Skeleton, anim: &ModelAnimation, t: f32) -> Vec<Mat4> {
let sample_v = |keys: &[(f32, [f32; 3])], t: f32, dflt: Vec3| -> Vec3 {
if keys.is_empty() {
return dflt;
}
match keys.iter().position(|&(kt, _)| kt >= t) {
None => Vec3::from(keys[keys.len() - 1].1),
Some(0) => Vec3::from(keys[0].1),
Some(i) => {
let (t0, a) = keys[i - 1];
let (t1, b) = keys[i];
let f = if t1 > t0 { (t - t0) / (t1 - t0) } else { 0.0 };
Vec3::from(a).lerp(Vec3::from(b), f)
}
}
};
let sample_q = |keys: &[(f32, [f32; 4])], t: f32| -> Quat {
if keys.is_empty() {
return Quat::IDENTITY;
}
let q = |v: [f32; 4]| Quat::from_xyzw(v[0], v[1], v[2], v[3]).normalize();
match keys.iter().position(|&(kt, _)| kt >= t) {
None => q(keys[keys.len() - 1].1),
Some(0) => q(keys[0].1),
Some(i) => {
let (t0, a) = keys[i - 1];
let (t1, b) = keys[i];
let f = if t1 > t0 { (t - t0) / (t1 - t0) } else { 0.0 };
q(a).slerp(q(b), f)
}
}
};
let mut out = vec![Mat4::IDENTITY; skel.bones.len()];
for (i, bone) in skel.bones.iter().enumerate() {
let keys = anim.bones.iter().find(|k| k.bone as usize == i);
let (tr, rot, sc) = match keys {
Some(k) => (
sample_v(&k.translation, t, Vec3::ZERO),
sample_q(&k.rotation, t),
sample_v(&k.scale, t, Vec3::ONE),
),
None => (Vec3::ZERO, Quat::IDENTITY, Vec3::ONE),
};
let pivot = Vec3::from(bone.pivot);
let local = Mat4::from_translation(pivot + tr)
* Mat4::from_quat(rot)
* Mat4::from_scale(sc)
* Mat4::from_translation(-pivot);
out[i] = match bone.parent {
p if p >= 0 && (p as usize) < i => out[p as usize] * local,
_ => local,
};
}
out
}
/// Skin one vertex by its 4 bone/weight pairs (weights already normalised by the parser).
fn skin(p: [f32; 3], j: [u16; 4], w: [f32; 4], pal: &[Mat4]) -> Vec3 {
let v = Vec3::from(p);
let mut acc = Vec3::ZERO;
let mut total = 0.0;
for k in 0..4 {
if w[k] <= 0.0 {
continue;
}
let Some(m) = pal.get(j[k] as usize) else {
continue;
};
acc += m.transform_point3(v) * w[k];
total += w[k];
}
if total > 0.0 {
acc / total
} else {
v
}
}
/// Every skinned triangle of the depth-writing batches, plus the nearest-vertex distance to `probe`.
fn shell(subs: &[RenderSubmesh], pal: &[Mat4], probe: Vec3) -> (Vec<[Vec3; 3]>, f32) {
let mut tris = Vec::new();
let mut near = f32::MAX;
for s in subs {
if s.no_depth_write || s.no_depth_test || s.joints.is_empty() {
continue;
}
let pos: Vec<Vec3> = (0..s.positions.len())
.map(|i| skin(s.positions[i], s.joints[i], s.weights[i], pal))
.collect();
for &p in &pos {
near = near.min(p.distance(probe));
}
for t in s.indices.chunks_exact(3) {
tris.push([pos[t[0] as usize], pos[t[1] as usize], pos[t[2] as usize]]);
}
}
(tris, near)
}
fn ray_hits(tris: &[[Vec3; 3]], orig: Vec3, dir: Vec3) -> bool {
tris.iter().any(|t| {
let (e1, e2) = (t[1] - t[0], t[2] - t[0]);
let h = dir.cross(e2);
let det = e1.dot(h);
if det.abs() < 1e-9 {
return false;
}
let inv = 1.0 / det;
let s = orig - t[0];
let u = inv * s.dot(h);
if !(0.0..=1.0).contains(&u) {
return false;
}
let q = s.cross(e1);
let v = inv * dir.dot(q);
if v < 0.0 || u + v > 1.0 {
return false;
}
inv * e2.dot(q) > 1e-6
})
}
/// Surviving area fraction of the constant-depth quad at `center`, viewed along `view`.
fn pass_fraction(tris: &[[Vec3; 3]], center: Vec3, half: f32, view: Vec3, n: usize) -> f32 {
let up = if view.z.abs() > 0.9 { Vec3::X } else { Vec3::Z };
let right = view.cross(up).normalize();
let real_up = right.cross(view).normalize();
let mut passed = 0usize;
for iy in 0..n {
for ix in 0..n {
let fx = (ix as f32 + 0.5) / n as f32 * 2.0 - 1.0;
let fy = (iy as f32 + 0.5) / n as f32 * 2.0 - 1.0;
let p = center + right * (fx * half) + real_up * (fy * half);
if !ray_hits(tris, p, -view) {
passed += 1;
}
}
}
passed as f32 / (n * n) as f32
}
fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
let virt = args
.next()
.ok_or_else(|| anyhow::anyhow!("usage: eye_burial_anim <m2 path> [seq slot]"))?;
let slot: usize = args.next().and_then(|s| s.parse().ok()).unwrap_or(0);
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let mut chain = benilla_formats::open_chain(&data)?;
let bytes = chain.read_file(&virt)?;
let subs = benilla_formats::parse_m2_render_submeshes(&bytes, "", &[])?;
let skel = benilla_formats::parse_m2_skeleton(&bytes)?;
let anims = benilla_formats::parse_m2_animations(&bytes);
let anim = anims
.iter()
.find(|a| a.seq_index == slot)
.ok_or_else(|| anyhow::anyhow!("no sequence in file slot {slot}"))?;
let emitters = benilla_formats::parse_m2_particle_emitters(&bytes)?;
println!(
"{virt} seq slot {slot} (anim id {}, {:.3}s, {} bones keyed)",
anim.anim_id,
anim.duration,
anim.bones.len()
);
// Which bones does the depth-writing shell actually skin to, and does any of them sit under a
// BILLBOARD bone? A billboard joint is re-aimed at the camera every frame and its children
// inherit that rotation, so shell geometry under one moves with the *camera* — which is exactly
// the signature of a burial that appears from one camera elevation and not another.
{
let mut shell_bones: Vec<usize> = Vec::new();
for s in &subs {
if s.no_depth_write || s.no_depth_test {
continue;
}
for (j, w) in s.joints.iter().zip(&s.weights) {
for k in 0..4 {
if w[k] > 0.0 && !shell_bones.contains(&(j[k] as usize)) {
shell_bones.push(j[k] as usize);
}
}
}
}
shell_bones.sort_unstable();
let billboard: Vec<usize> = skel
.bones
.iter()
.enumerate()
.filter(|(_, b)| b.billboard.is_some())
.map(|(i, _)| i)
.collect();
let mut tainted = Vec::new();
for &b in &shell_bones {
let (mut cur, mut hops) = (b, 0);
while cur < skel.bones.len() && hops < 64 {
if skel.bones[cur].billboard.is_some() {
tainted.push((b, cur));
break;
}
match skel.bones[cur].parent {
p if p >= 0 => cur = p as usize,
_ => break,
}
hops += 1;
}
}
println!(
"shell skins to {} bones {:?}\nbillboard bones: {billboard:?}\nshell bones under a billboard: {tainted:?}",
shell_bones.len(),
shell_bones
);
}
for e in &emitters {
let b = e.bone as usize;
if b >= skel.bones.len() {
continue;
}
// Only the additive quad emitters mounted flush in the shell are the B16 subject; the
// others are printed anyway so the contrast is on the same page.
println!(
"\nemitter bone {b} pos ({:.4},{:.4},{:.4}) blend {:?}",
e.position[0], e.position[1], e.position[2], e.blend
);
println!(" t gap(m) vs bind pass%(front) pass%(30 above)");
let mut bind_gap = f32::NAN;
for step in 0..=8 {
let t = anim.duration * step as f32 / 8.0;
let pal = palette(&skel, anim, if step == 0 { 0.0 } else { t });
// Where our particles are born: the emitter record position through the same palette.
let center = pal[b].transform_point3(Vec3::from(e.position));
let (tris, near) = shell(&subs, &pal, center);
if step == 0 {
bind_gap = near;
}
let front = pass_fraction(&tris, center, 0.0833, Vec3::new(-1.0, 0.0, 0.0), 48);
let el = 30f32.to_radians();
let above = pass_fraction(
&tris,
center,
0.0833,
Vec3::new(-el.cos(), 0.0, -el.sin()),
48,
);
println!(
" {t:5.3} {near:7.4} {:+7.4} {:5.1} {:5.1}",
near - bind_gap,
front * 100.0,
above * 100.0
);
}
}
Ok(())
}
@@ -228,6 +228,18 @@ enum Command {
/// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted.
prefix: Option<String>,
},
/// Sweep every `.m2` (optionally under a path prefix) and count the two halves of the
/// **owner-last draw-order** law per model: the EFFECTS it authors (particle emitters +
/// ribbon trails) and the TRANSPARENT-pass batches of its own body those effects must draw
/// after (decisions 0719/0721), plus the model's reach and the draw-order rung that reach
/// produces. The population instrument for "how much does this fix, besides the one creature
/// it was found on": a model with effects AND transparent batches of its own is one whose
/// effects our distance sort could interleave with its own body; one without never had the
/// defect at all.
Fxordercensus {
/// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted.
prefix: Option<String>,
},
/// Sweep every `.m2` (optionally under a path prefix) and list the models whose PARTICLE
/// emitters carry any of the given file-flag bits (`M2ParticleEmitter+0x04`) — the
/// population instrument for particle-flag mechanisms (which content actually authors
@@ -490,6 +502,7 @@ fn main() -> Result<()> {
Command::Goanimscan => scan::goanimscan(&mut chain)?,
Command::Bonescan { prefix } => scan::bonescan(&mut chain, prefix.as_deref())?,
Command::Partcensus { prefix } => scan::partcensus(&mut chain, prefix.as_deref())?,
Command::Fxordercensus { prefix } => scan::fxordercensus(&mut chain, prefix.as_deref())?,
Command::Partscan { mask, prefix } => {
let mask = parse_u32_maybe_hex(&mask)
.with_context(|| format!("parsing flag mask '{mask}'"))?;
@@ -2378,3 +2378,97 @@ pub fn cellscan(chain: &mut Chain) -> Result<()> {
);
Ok(())
}
/// Sweep every `.m2` (optionally under a path prefix) and count, per model, the two halves of the
/// **owner-last draw-order** law: the EFFECTS a model authors (particle emitters + ribbon trails)
/// and the TRANSPARENT-pass batches of its own body those effects must draw after (decisions
/// 0719/0721). A model with both is one the rung actually changes; a model with effects and no
/// transparent batch of its own never had the defect and is listed only in the totals.
///
/// This is the population instrument the two decisions were argued from. "Does this fix anything
/// besides the voidwalker's eyes?" is not a question to answer by naming plausible creatures —
/// it is a count, and the count is what says whether the mechanism closes a class or a case.
pub fn fxordercensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\"));
let names: Vec<String> = chain
.list()
.context("listing chain contents")?
.into_iter()
.map(|e| e.name)
.filter(|n| {
let l = n.to_ascii_lowercase();
l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p))
})
.collect();
// Per top-level content family (Creature / Item / Spells / World / …), so the totals say
// WHERE the class lives rather than only how big it is.
let mut family: BTreeMap<String, (u32, u32)> = BTreeMap::new();
let (mut scanned, mut with_fx, mut at_risk) = (0u32, 0u32, 0u32);
let mut rungs: BTreeMap<u32, u32> = BTreeMap::new();
for name in names {
let Ok(bytes) = chain.read_file(&name) else {
continue;
};
scanned += 1;
let emitters = benilla_formats::parse_m2_particle_emitters(&bytes)
.map(|e| e.len())
.unwrap_or(0);
let trails = benilla_formats::m2_ribbon_emitter_count(&bytes);
if emitters + trails == 0 {
continue;
}
with_fx += 1;
// The occluders: batches the renderer puts in the one distance-sorted transparent list.
// `additive` forces that pass whatever the authored blend says (`model_material`), so the
// test mirrors the renderer's rather than reading the blend word alone.
let dir = name.rsplit_once('\\').map_or("", |(d, _)| d);
let subs = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default();
let occluders: Vec<&benilla_formats::RenderSubmesh> = subs
.iter()
.filter(|s| {
s.additive
|| matches!(
s.blend,
benilla_formats::ModelBlend::Blend
| benilla_formats::ModelBlend::Mod
| benilla_formats::ModelBlend::Mod2x
)
})
.collect();
let transparent = occluders.len();
// The renderer's own bound and the renderer's own rung — not a re-derivation of them.
let reach = benilla_formats::m2_owner_reach(&subs);
let fam = name.split('\\').next().unwrap_or("?").to_string();
family.entry(fam).or_default().0 += 1;
if transparent == 0 {
continue; // effects, but nothing of its own that could paint over them
}
at_risk += 1;
family
.entry(name.split('\\').next().unwrap_or("?").to_string())
.or_default()
.1 += 1;
// At placement scale 1 — the survey number; a scaled placement multiplies the reach.
let rung = benilla_formats::owner_last_rung(reach);
*rungs.entry(rung as u32).or_default() += 1;
println!(
"{transparent:>3} transp {emitters:>2} emit {trails:>2} ribb reach {reach:>8.2} \
rung {rung:>2.0} {name}"
);
}
eprintln!(
"{scanned} models scanned\n \
{with_fx} author effects (emitters and/or ribbons)\n \
{at_risk} of those ALSO author transparent batches of their own — the population the \
owner-last rung changes"
);
eprintln!(" by family (with-effects / at-risk):");
for (fam, (n, risk)) in &family {
eprintln!(" {fam:<24} {n:>5} / {risk:>5}");
}
eprintln!(" rung distribution (at-risk models, placement scale 1):");
for (rung, n) in &rungs {
eprintln!(" rung {rung:>2} {n:>5}");
}
Ok(())
}
+15 -14
View File
@@ -145,20 +145,21 @@ pub use models::{
accumulate_wmo_group_camera_collision, accumulate_wmo_group_camera_only_collision,
accumulate_wmo_group_collision, hand_grip_finger_poses, load_m2_animation_summary,
load_m2_bounds, load_m2_collision_hull, load_m2_mesh, load_m2_mesh_skinned, load_object_model,
load_wmo, load_wmo_collision_tris, m2_ribbon_emitter_count, m2_texture_transform_count,
parse_m2_animation_lookup, parse_m2_animation_summary, parse_m2_animations,
parse_m2_attachments, parse_m2_bounds, parse_m2_camera, parse_m2_collision_hull,
parse_m2_event_markers, parse_m2_global_sequence_bones, parse_m2_lights,
parse_m2_playable_animation_lookup, parse_m2_portrait_camera, parse_m2_render_submeshes,
parse_m2_skeleton, parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights,
parse_wmo_portals, parse_wmo_root, wmo_group_doodad_refs, wmo_group_footprint_tris,
wmo_group_header, wmo_group_light_refs, wmo_group_liquid_mesh, wmo_group_submeshes,
wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent, Billboard, BillboardKind, BoneKeys, BoneScaleAnim,
CharSkinSlot, CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris,
GlobalSeqBone, GlobalSeqChannel, GroundQuad, M2AnimSummary, M2Attachment, M2Bounds, M2Light,
M2PortraitCamera, ModelAnimation, ModelBlend, PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim,
Skeleton, SkeletonBone, StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog,
WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
load_wmo, load_wmo_collision_tris, m2_owner_reach, m2_ribbon_emitter_count,
m2_texture_transform_count, owner_last_rung, parse_m2_animation_lookup,
parse_m2_animation_summary, parse_m2_animations, parse_m2_attachments, parse_m2_bounds,
parse_m2_camera, parse_m2_collision_hull, parse_m2_event_markers,
parse_m2_global_sequence_bones, parse_m2_lights, parse_m2_playable_animation_lookup,
parse_m2_portrait_camera, parse_m2_render_submeshes, parse_m2_skeleton,
parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights, parse_wmo_portals, parse_wmo_root,
wmo_group_doodad_refs, wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs,
wmo_group_liquid_mesh, wmo_group_submeshes, wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent,
Billboard, BillboardKind, BoneKeys, BoneScaleAnim, CharSkinSlot, CollisionMesh,
EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone, GlobalSeqChannel,
GroundQuad, M2AnimSummary, M2Attachment, M2Bounds, M2Light, M2PortraitCamera, ModelAnimation,
ModelBlend, PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, Skeleton, SkeletonBone,
StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader,
WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot,
};
mod terrain;
pub use terrain::{
@@ -592,6 +592,77 @@ fn is_white1_placeholder(bbox_min: [f32; 3], bbox_max: [f32; 3], subs: &[RenderS
})
}
/// How far a model's own **transparent-pass batches** sort from its origin, model-local yards —
/// the bound the renderer's owner-last draw-order rung is sized from (decisions 0719/0721).
///
/// The reference draws a model's emitters in their own bracket *after* that model's batches; we put
/// both in one distance-sorted transparent list, so an effect has to be biased past every batch of
/// its owner — and no further, or it also jumps other models' transparents. What it must clear is
/// therefore the farthest point any of those batches SORTS at, and a batch sorts at its own
/// bind-pose AABB **centre**, not at its farthest vertex.
///
/// That distinction is the whole reason this function exists rather than reading
/// [`super::M2Bounds::vert_max_from_origin`]. Measured over the 1.12.1 corpus's 967 at-risk models,
/// swapping the vertex bound for this one drops 83 models past the rung ceiling to 46 and moves 144
/// models down to rung 1 — a waterfall's single long batch drags the vertex bound to hundreds of
/// yards while every batch centre sits a few yards from the origin.
///
/// Only transparent-pass batches count: an opaque or alpha-tested batch is binned in `Opaque3d` /
/// `AlphaMask3d` and settles against the effect by depth, never by list order. The blend test
/// mirrors the renderer's own (`benilla::model_render::model_material`), where `additive` forces the
/// transparent pass whatever the authored blend word says. A model with no transparent batch at all
/// returns `0.0` — nothing of its own can paint over its effects, and rung 1 still holds the law.
pub fn m2_owner_reach(subs: &[RenderSubmesh]) -> f32 {
subs.iter()
.filter(|s| {
s.additive
|| matches!(
s.blend,
ModelBlend::Blend | ModelBlend::Mod | ModelBlend::Mod2x
)
})
.filter(|s| !s.positions.is_empty())
.map(|s| {
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
for p in &s.positions {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
// Distance from the origin, so the raw-WoW/Bevy axis question doesn't arise: the two
// frames differ by a signed axis permutation, which preserves length.
let c: [f32; 3] = std::array::from_fn(|k| 0.5 * (lo[k] + hi[k]));
(c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt()
})
.fold(0.0f32, f32::max)
}
/// The **owner-last** draw-order rung for an effect whose owner reaches `reach_world` world yards:
/// the `Transparent3d` depth bias that puts it after every transparent batch of its own model and
/// no further (`benilla::particles::owner_last_bias` is the one caller that applies it; `emdump`
/// and `benilla-extract fxordercensus` print it).
///
/// It lives here, beside [`m2_owner_reach`], because a rung computed three times in three crates
/// drifts three ways — and this is the number the survey tools have to print to be worth reading.
///
/// Snapped to the next whole yard **above** the reach, for two reasons. Bevy folds
/// `depth_bias as i32` into the material's *pipeline key*, so a free-floating float would specialize
/// a fresh pipeline per distinct model radius; and `≥` has to become `>`, or a batch centred exactly
/// at the reach ties with the effect and the order falls back to whatever the queue emitted.
/// `floor + 1` does both. Over-biasing costs only ordering against OTHER models' transparents within
/// the rung, so the rounding goes up; under-biasing reinstates the defect.
pub fn owner_last_rung(reach_world: f32) -> f32 {
// The ceiling keeps this rung far below every rung in `benilla::sky_order`'s ladder (the
// nearest, the ground-fx one, is four orders of magnitude up). 46 corpus models reach past it
// — Maraudon's waterfalls, the Dire Maul vortex, the glue screens, whose transparent batches
// spread 30–780 yd from their origin. Those keep the interleave: biasing an effect past a
// 200-yard owner would put it in front of every transparent surface in the zone, which is the
// worse of the two errors (decision 0721).
const MAX_RUNG: f32 = 32.0;
(reach_world.max(0.0).floor() + 1.0).min(MAX_RUNG)
}
#[cfg(test)]
mod tests {
use super::*;
+22
View File
@@ -277,6 +277,28 @@ impl super::UiScript {
pub fn world_map_selection(&self) -> (u32, u32) {
self.model_ref().worldmap.selection
}
/// The **normalized position within the map art** at UI-space `(x, y)`, or `None` when the
/// point isn't over it. Reproduces the reference's own click normalization verbatim —
/// `WorldMapFrame.xml`'s `WorldMapButton_OnClick`: `u = (x − left)/width`,
/// `v = (top − y)/height` over `WorldMapButton`'s rect — so the UV handed back is the same one
/// [`ProcessMapClick`](install) reads and the same one the blips are placed in. The Lua's
/// effective-scale division cancels here: point and rect are both already in screen units.
///
/// A pure query (fires nothing, mutates nothing). Its consumer is the app-side dev map-jump,
/// which needs a click's UV *without* going through the faithful click path — that path drills
/// into a zone, and the reference's law for it must not grow a second meaning.
pub fn world_map_uv_at(&self, x: f32, y: f32) -> Option<(f32, f32)> {
let id = self.hit_test(x, y)?;
let model = self.model_ref();
let fh = model.id_to_frame.get(&id).copied()?;
if model.arena.frame(fh)?.name.as_deref() != Some("WorldMapButton") {
return None;
}
let r = model.resolved.get(&fh)?;
let (w, h) = (r.right - r.left, r.top - r.bottom);
(w > 0.0 && h > 0.0).then(|| ((x - r.left) / w, (r.top - y) / h))
}
}
/// Clamp + store a selection and queue the repaint event. The shared tail of
+251 -3
View File
@@ -28,6 +28,24 @@
//! physical pixels. (`WOW_PICK` has to divide by the window scale factor; its ray cast works in
//! logical units. Same input space, different reason.)
//!
//! ## `WOW_DEPTH_QUADS` — the same reading, taken at a particle quad's OWN pixels
//!
//! A hand-written pixel list is the wrong instrument for a *moving* subject. B16's eye glow is two
//! additive quads a few dozen pixels across, riding an animated bone: name a pixel and by the frame
//! the readback lands the quad is somewhere else, and a grid coarse enough to catch it samples the
//! head, the ground and the neighbouring unit instead (that mis-measurement is retracted).
//!
//! `WOW_DEPTH_QUADS=<bone>[,<bone>…]` (empty value = every quad emitter) reads the emitter's **live
//! vertex buffer** — the four corners `expand_quads` actually wrote, the ones the GPU is about to
//! rasterize — projects them with the frame's own matrices, and samples the depth buffer on a
//! bilinear grid *inside that quad*. What comes back is the number the offline model predicts and
//! the reference measures: the **surviving area fraction** of the quad under `GreaterEqual`, plus
//! how deep the winning surface sits in front of it, in yards. No camera hunting, no pixel guessing,
//! and no way to accidentally measure a different surface.
//!
//! Frames with no live quad are skipped entirely (no copy, no frame counted), so the burst lands on
//! the frames where the subject exists rather than on a wall clock.
//!
//! **MSAA must be off** (`WOW_MSAA=off`). A multisampled depth texture cannot be copied to a buffer
//! at all, and there is no single "the" depth at a pixel to report if it could — there are four. The
//! probe refuses rather than reporting one of them, because a number that looks like the measurement
@@ -50,6 +68,7 @@ use bevy::render::renderer::{RenderContext, RenderDevice};
use bevy::render::view::{ExtractedView, ViewDepthTexture};
use bevy::render::{Render, RenderApp, RenderSystems};
use crate::particles::ParticleEmitter;
use crate::player::WorldCamera;
pub(crate) struct DepthProbePlugin;
@@ -60,14 +79,21 @@ impl Plugin for DepthProbePlugin {
.ok()
.map(|s| parse_pixels(&s))
.unwrap_or_default();
if pixels.is_empty() {
warn!("depth: WOW_DEPTH wants \"<x>,<y>[;<x>,<y>…]\" screenshot pixels — inert");
let quad_bones = parse_bones(std::env::var("WOW_DEPTH_QUADS").ok().as_deref());
if pixels.is_empty() && quad_bones.is_none() {
warn!(
"depth: WOW_DEPTH wants \"<x>,<y>[;<x>,<y>…]\" screenshot pixels (or set \
WOW_DEPTH_QUADS) — inert"
);
return;
}
// Quad mode arms at once: the subject is a particle pool that does not exist until its
// model spawns, and the frame gate below (report only frames that HAVE quads) is a
// sharper window than any wall clock the caller could guess.
let at = std::env::var("WOW_DEPTH_AT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20.0);
.unwrap_or(if quad_bones.is_some() { 0.0 } else { 20.0 });
let count = std::env::var("WOW_DEPTH_COUNT")
.ok()
.and_then(|v| v.parse().ok())
@@ -79,9 +105,16 @@ impl Plugin for DepthProbePlugin {
count,
armed: false,
})
.insert_resource(QuadWatch(quad_bones))
.init_resource::<QuadProbes>()
.add_systems(Update, arm)
.add_systems(
PostUpdate,
collect_quads.after(crate::billboard::BillboardPlace),
)
.add_plugins((
ExtractResourcePlugin::<DepthWatch>::default(),
ExtractResourcePlugin::<QuadProbes>::default(),
ExtractComponentPlugin::<DepthProbeView>::default(),
));
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
@@ -148,6 +181,125 @@ impl ExtractResource for DepthWatch {
}
}
/// `$WOW_DEPTH_QUADS`'s bone scope: `None` = the mode is off, `Some([])` = every quad emitter.
#[derive(Resource)]
struct QuadWatch(Option<Vec<u16>>);
/// One live particle quad, carried to the render world in the space the depth buffer is read in.
#[derive(Clone, Copy)]
struct QuadProbe {
bone: u16,
/// Index within the emitter's mesh — quads are written oldest-first, so 0 is the smallest,
/// dimmest particle and the last is the one born this frame (the big, bright one that decides
/// whether the glow reads at all).
index: u32,
/// The four corners in physical pixels, in `expand_quads`' own vertex order.
corners: [Vec2; 4],
/// The NDC depth the corners carry. Read from the projected corners, not assumed constant —
/// the *spread* is reported, because a billboard that is not a constant-depth plane is a
/// different bug wearing the same symptom (wow-re `part-flush-emitter-depth.md` §4).
dquad: f32,
dspread: f32,
/// The quad centre's distance to the camera plane, yards — the unit the burial is reported in.
viewz: f32,
}
/// This frame's live quads, in `$WOW_DEPTH_QUADS` scope. Empty on every frame in every run that
/// did not ask for the mode.
#[derive(Resource, Clone, Default)]
struct QuadProbes(Vec<QuadProbe>);
impl ExtractResource for QuadProbes {
type Source = QuadProbes;
fn extract_resource(source: &Self::Source) -> Self {
source.clone()
}
}
/// Project every in-scope emitter's live quads into pixels, once a frame.
///
/// Deliberately reads the **mesh asset**, not the simulation: these are the vertices the draw will
/// consume, so a quad that was mis-built (collapsed, mis-billboarded, left at last frame's anchor)
/// is measured as itself rather than as what the sim intended. Runs after `BillboardPlace`, the set
/// that rebuilds the meshes, so the buffer read is this frame's.
fn collect_quads(
watch: Res<QuadWatch>,
mut probes: ResMut<QuadProbes>,
cam: Query<(&Camera, &GlobalTransform, &Projection), With<WorldCamera>>,
emitters: Query<(&ParticleEmitter, &GlobalTransform, &Mesh3d)>,
meshes: Res<Assets<Mesh>>,
) {
let Some(bones) = watch.0.as_deref() else {
return;
};
probes.0.clear();
let (Ok((camera, cam_tf, projection)),) = (cam.single(),) else {
return;
};
let Some(vp) = camera.physical_viewport_size() else {
return;
};
// The frame's own matrices, the same pair `depthdump` projects with — so a quad's `dquad` here
// and its `dquad` there are the same number, and the two logs can be read against each other.
let clip_from_world = projection.get_clip_from_view() * cam_tf.to_matrix().inverse();
for (emitter, gt, mesh) in &emitters {
if !bones.is_empty() && !bones.contains(&emitter.bone()) {
continue;
}
let Some(bevy::mesh::VertexAttributeValues::Float32x3(pos)) = meshes
.get(&mesh.0)
.and_then(|m| m.attribute(Mesh::ATTRIBUTE_POSITION))
else {
continue;
};
for (index, quad) in pos.chunks_exact(4).enumerate() {
let mut corners = [Vec2::ZERO; 4];
let (mut dmin, mut dmax, mut center) = (f32::MAX, f32::MIN, Vec3::ZERO);
let mut behind = false;
for (i, v) in quad.iter().enumerate() {
// Verts are anchor-relative (the transparent-pass sort key); the entity transform
// is the anchor.
let world = gt.transform_point(Vec3::from(*v));
center += world / 4.0;
let clip = clip_from_world * world.extend(1.0);
if clip.w <= 0.0 {
behind = true;
break;
}
let ndc = clip.truncate() / clip.w;
corners[i] = Vec2::new(
(ndc.x + 1.0) * 0.5 * vp.x as f32,
(1.0 - ndc.y) * 0.5 * vp.y as f32,
);
dmin = dmin.min(ndc.z);
dmax = dmax.max(ndc.z);
}
if behind {
continue;
}
probes.0.push(QuadProbe {
bone: emitter.bone(),
index: index as u32,
corners,
dquad: (dmin + dmax) * 0.5,
dspread: dmax - dmin,
viewz: -(cam_tf.to_matrix().inverse() * center.extend(1.0)).z,
});
}
}
}
/// `"60,61"` → the bone scope; an empty or absent value means every quad emitter. `None` when the
/// variable is unset at all, which is what turns the whole mode off.
fn parse_bones(spec: Option<&str>) -> Option<Vec<u16>> {
Some(
spec?
.split(',')
.filter_map(|b| b.trim().parse().ok())
.collect(),
)
}
/// Marks the one view whose depth to read. The depth texture is cached per *render target*, so the
/// world camera and the UI camera on the same window share it — but picking a view by viewport size
/// would be picking by coincidence, and this bug is made of numbers taken slightly wrong.
@@ -267,6 +419,15 @@ impl ViewNode for DepthReadbackNode {
if !watch.armed || world.resource::<DepthFramesRead>().0 >= watch.count {
return Ok(());
}
// Quad mode with nothing live this frame: no copy, and `read_depth` will not count it —
// the burst spends its frames on the ones that have the subject in them.
if watch.pixels.is_empty()
&& world
.get_resource::<QuadProbes>()
.is_none_or(|q| q.0.is_empty())
{
return Ok(());
}
let size = depth.texture.size();
// `COPY_SRC` only lands on the texture allocated *after* `arm` patched the camera, so the
// first armed frame still has the old one. Skip it rather than trip wgpu validation.
@@ -308,6 +469,7 @@ struct DepthFramesRead(u32);
/// Map back what the node copied and log the named pixels.
fn read_depth(
watch: Option<Res<DepthWatch>>,
quads: Option<Res<QuadProbes>>,
view: Query<&ExtractedView, With<DepthProbeView>>,
device: Res<RenderDevice>,
staging: Option<Res<DepthStaging>>,
@@ -320,6 +482,12 @@ fn read_depth(
if !watch.armed || read.0 >= watch.count {
return;
}
// Mirrors the node's quad-mode skip: no copy was encoded, so there is nothing to read and this
// frame does not spend one of the burst's slots.
let quads = quads.map(|q| q.0.clone()).unwrap_or_default();
if watch.pixels.is_empty() && quads.is_empty() {
return;
}
let (Ok(view), Ok(depth)) = (view.single(), depth.single()) else {
return;
};
@@ -379,10 +547,90 @@ fn read_depth(
None => info!("depth#{frame} ({x}, {y}): {d:.9} = nothing drew (cleared)"),
}
}
for q in &quads {
report_quad(frame, q, &data, &staging, size.width, size.height);
}
}
staging.buffer.unmap();
}
/// Samples in each direction across a quad's own area — 16×16 = 256 tests, fine enough that the
/// fraction is stable to well under a percent against the offline model's 48×48 and 64×64 grids,
/// cheap enough to run on every quad of a pool.
const QUAD_GRID: usize = 16;
/// Run one quad's depth contest and log it.
///
/// Reverse-Z, `GreaterEqual` (the particle material takes Bevy's default compare): the fragment
/// survives iff `dquad >= dbuffer`. Sampling walks the quad's own `(u, v)` by bilinear interpolation
/// of its four projected corners, so every sample is inside the quad by construction — including a
/// spun quad, whose screen AABB is up to 41 % larger than the quad itself.
fn report_quad(
frame: u32,
q: &QuadProbe,
data: &[u8],
staging: &DepthStaging,
width: u32,
height: u32,
) {
let (mut passed, mut total) = (0usize, 0usize);
// Reverse-Z: the LARGEST buffer value is the NEAREST surface, so `dmax` is the closest thing
// standing in the quad's way and `dmin` the furthest.
let (mut dmin, mut dmax) = (f32::MAX, f32::MIN);
let mut cleared = 0usize;
for iy in 0..QUAD_GRID {
for ix in 0..QUAD_GRID {
let u = (ix as f32 + 0.5) / QUAD_GRID as f32;
let v = (iy as f32 + 0.5) / QUAD_GRID as f32;
let p = q.corners[0]
.lerp(q.corners[1], u)
.lerp(q.corners[3].lerp(q.corners[2], u), v);
let (x, y) = (p.x.floor(), p.y.floor());
if x < 0.0 || y < 0.0 || x >= width as f32 || y >= height as f32 {
continue;
}
let at = (y as u32 * staging.bytes_per_row + x as u32 * 4) as usize;
let d = f32::from_le_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]);
total += 1;
if q.dquad >= d {
passed += 1;
}
if d <= 0.0 {
cleared += 1;
} else {
dmin = dmin.min(d);
dmax = dmax.max(d);
}
}
}
if total == 0 {
info!(
"depth#{frame} quad bone={} i={}: entirely off screen",
q.bone, q.index
);
return;
}
// `dquad` and the buffer values live on the same reverse-Z curve, so the ratio converts either
// to yards without re-deriving the projection: view z scales as 1/d.
let yd = |d: f32| q.viewz * q.dquad / d;
let (near, far) = (yd(dmax), yd(dmin));
info!(
"depth#{frame} quad bone={} i={} px=({:.0},{:.0}) dquad={:.9} spread={:.9} \
viewz={:.4} pass={:.1}% ({passed}/{total}) occluder {near:.4}..{far:.4} yd \
burial {:.4}..{:.4} yd cleared={cleared}",
q.bone,
q.index,
q.corners[0].lerp(q.corners[2], 0.5).x,
q.corners[0].lerp(q.corners[2], 0.5).y,
q.dquad,
q.dspread,
q.viewz,
passed as f32 / total as f32 * 100.0,
q.viewz - far,
q.viewz - near,
);
}
/// A physical pixel's centre in NDC. Framebuffer rows run down, NDC y runs up.
fn ndc_of(x: u32, y: u32, width: u32, height: u32) -> Vec2 {
Vec2::new(
+82 -11
View File
@@ -74,8 +74,8 @@ pub(crate) use probe_partner::ProbePartnerPlugin;
pub(crate) use probe_rig::{rig_char_name_from_env, ProbeRigPlugin};
pub(crate) use probe_taxi::ProbeTaxiPlugin;
pub(crate) use probes::{
LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin, ProbeExitPlugin,
ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin,
EntityCensusPlugin, LiveFpsPlugin, NodeProbePlugin, ParticleCensusPlugin, ProbeChatPlugin,
ProbeExitPlugin, ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin,
};
use scenarios::{Scenario, UiFixture, GROUND_EYE, SCENARIOS};
@@ -96,6 +96,17 @@ pub(crate) fn scenario_active() -> bool {
/// WOW_FX_AGE=1.2 WOW_FX_AZ=60 WOW_FX_EL=15 WOW_CAPTURE_OUT=/tmp/fx.png cargo run -q -p benilla
/// ```
///
/// **`WOW_FX_DISPLAY=<CreatureDisplayInfo id>` swaps the subject onto the UNIT path** — the same
/// component set a streamed creature gets, seated on the terrain, with everything that hangs off
/// being a unit (tag alpha, the distance-fade gate, anim LOD, the emitter's sequence host). Model
/// lane vs unit lane at one knob is what turns "does this creature look wrong?" into a headless
/// A/B instead of a director round-trip:
///
/// ```text
/// WOW_DATA=<Data> WOW_CAPTURE=fxview WOW_FX_DISPLAY=1132 WOW_FX_AGE=6 WOW_FX_EL=20 \
/// WOW_FX_DIST=12 WOW_CAPTURE_OUT=/tmp/vw.png cargo run -q -p benilla
/// ```
///
/// Knobs: `WOW_FX_MODEL` (required, internal path), `WOW_FX_AGE` (seconds after attach, default
/// 1.0), `WOW_FX_AZ`/`WOW_FX_EL` (camera orbit degrees, default 0/10), `WOW_FX_DIST` (yards,
/// default 5), `WOW_FX_FLY` (yd/s along the model's facing — a missile only trails in motion;
@@ -109,6 +120,15 @@ pub(crate) fn scenario_active() -> bool {
#[derive(Resource)]
pub(crate) struct FxViewRequest {
pub(crate) model_path: String,
/// `WOW_FX_DISPLAY=<CreatureDisplayInfo id>` — spawn the subject as a real **unit** (the live
/// `NetEntity` component set, the same path a streamed creature takes) instead of attaching it
/// as an effect. The two lanes differ in everything that hangs off being a unit — material tag
/// alpha, the distance-fade gate, anim LOD, the emitter's sequence host — so switching this one
/// knob is the A/B that says whether a defect belongs to the model or to the unit path.
/// `WOW_FX_MODEL` is then optional (the display id names the model).
pub(crate) display: Option<u32>,
/// `WOW_FX_SCALE` — the unit lane's `NetEntity::scale` (the wire scale a creature carries).
pub(crate) scale: f32,
pub(crate) age: f32,
pub(crate) az_deg: f32,
pub(crate) el_deg: f32,
@@ -222,6 +242,25 @@ struct CaptureCtx {
probe_frames: u32,
/// Probe samples (frame ms).
probe_samples: Vec<f32>,
/// Process CPU seconds at the first sampled frame — the window baseline for the probe line's
/// `cpu_ms`/`cpu_pct` (the load-robust metric, decision 0711; the scenario probe lacked it).
probe_cpu_start: Option<f64>,
}
/// The present mode a perf probe uncaps to (also the live probe's — see `probes.rs`).
///
/// `AutoNoVsync`, measured, not assumed: explicit `Immediate` on macOS/Metal is a trap — A/B'd
/// 2026-07-27 (overlook-noon, release, twice each), it *rails* near 16.6 ms AND takes 1.0–1.5 s
/// stalls (the `-[CAMetalLayer nextDrawable]` timeout — drawable starvation), while `AutoNoVsync`
/// genuinely uncaps when macOS grants it (p50 12.7 ms on the same scene). 0362's "AutoNoVsync
/// doesn't uncap" was the *power state* withholding the grant, not the mode — no present mode
/// escapes that; `cpu_ms` on the probe line is the rail-proof metric. `WOW_PROBE_UNCAP=immediate`
/// re-runs the losing arm when macOS/wgpu move.
pub(crate) fn probe_uncap_mode() -> bevy::window::PresentMode {
match std::env::var("WOW_PROBE_UNCAP").as_deref() {
Ok("immediate") => bevy::window::PresentMode::Immediate,
_ => bevy::window::PresentMode::AutoNoVsync,
}
}
pub(crate) struct CapturePlugin;
@@ -233,9 +272,19 @@ impl Plugin for CapturePlugin {
// request from env. Not in SCENARIOS — `scripts/visual.sh`'s golden sweep must never
// run it (its output depends on the model/age/angle knobs, not just the name).
let scenario = if name == "fxview" {
let Ok(model_path) = std::env::var("WOW_FX_MODEL") else {
eprintln!("WOW_CAPTURE=fxview needs WOW_FX_MODEL=<internal .mdx/.m2 path>");
std::process::exit(2);
let display: Option<u32> = std::env::var("WOW_FX_DISPLAY")
.ok()
.and_then(|v| v.trim().parse().ok());
let model_path = match (std::env::var("WOW_FX_MODEL"), display) {
(Ok(p), _) => p,
(Err(_), Some(_)) => String::new(), // the unit lane names its model by display id
(Err(_), None) => {
eprintln!(
"WOW_CAPTURE=fxview needs WOW_FX_MODEL=<internal .mdx/.m2 path> or \
WOW_FX_DISPLAY=<CreatureDisplayInfo id>"
);
std::process::exit(2);
}
};
let knob = |k: &str, d: f32| {
std::env::var(k)
@@ -245,6 +294,8 @@ impl Plugin for CapturePlugin {
};
app.insert_resource(FxViewRequest {
model_path,
display,
scale: knob("WOW_FX_SCALE", 1.0),
age: knob("WOW_FX_AGE", 1.0),
az_deg: knob("WOW_FX_AZ", 0.0),
el_deg: knob("WOW_FX_EL", 10.0),
@@ -391,6 +442,7 @@ impl Plugin for CapturePlugin {
.and_then(|v| v.parse().ok())
.unwrap_or(0),
probe_samples: Vec::new(),
probe_cpu_start: None,
})
.add_systems(Update, pin_scene.in_set(WorldStage::Present))
// Before the UnitFeed pass: the seed stands in for wire data that in live play
@@ -540,7 +592,7 @@ fn drive_capture(
let keep_vsync = std::env::var("WOW_PROBE_VSYNC").as_deref() == Ok("1");
if !keep_vsync {
if let Ok(mut w) = windows.single_mut() {
w.present_mode = bevy::window::PresentMode::AutoNoVsync;
w.present_mode = probe_uncap_mode();
}
}
info!(
@@ -564,6 +616,7 @@ fn drive_capture(
}
Phase::ProbeWarmup(n) => {
if n + 1 >= PROBE_WARMUP_FRAMES {
ctx.probe_cpu_start = crate::perf::process_cpu_secs();
Phase::Probing(0)
} else {
Phase::ProbeWarmup(n + 1)
@@ -572,7 +625,9 @@ fn drive_capture(
Phase::Probing(n) => {
let ms = time.delta_secs() * 1000.0;
ctx.probe_samples.push(ms);
if n + 1 >= ctx.probe_frames {
// `==`, not `>=`: `AppExit` takes a frame or two to drain, and the re-entered finish
// branch used to print a second (301-frame) line in the gap.
if n + 1 == ctx.probe_frames {
let mut v = ctx.probe_samples.clone();
v.sort_by(f32::total_cmp);
let at = |q: f32| v[(((v.len() - 1) as f32) * q).round() as usize];
@@ -593,10 +648,28 @@ fn drive_capture(
(n + 1, d + usize::from(v.get()))
});
let entity_count = entities.iter().len();
// CPU cost per frame across every thread — the load-robust half of the measurement
// (`perf::process_cpu_secs`), same fields as the live probe's line.
let cpu = match (ctx.probe_cpu_start, crate::perf::process_cpu_secs()) {
(Some(t0), Some(t1)) => {
let per_frame_ms = (t1 - t0) * 1000.0 / v.len() as f64;
format!(
" cpu_ms={per_frame_ms:.2} cpu_pct={:.0}",
per_frame_ms / mean as f64 * 100.0
)
}
_ => String::new(),
};
// The present mode the window actually measured under — an uncap that silently
// rails (0362) is only diagnosable if the line says what was asked for.
let present = windows
.single()
.map(|w| format!(" present={:?}", w.present_mode))
.unwrap_or_default();
// Machine-greppable one-liner + a human block. stdout, not the log, so a script can
// capture it without log-filter noise.
println!(
"FPS_PROBE scenario={} frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} entities={entity_count} px={}x{}",
"FPS_PROBE scenario={} frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} entities={entity_count} px={}x{}{cpu}{present}",
ctx.scenario.name,
v.len(),
at(0.50),
@@ -608,10 +681,8 @@ fn drive_capture(
px.1,
);
exit.write(AppExit::Success);
Phase::Probing(n)
} else {
Phase::Probing(n + 1)
}
Phase::Probing(n + 1)
}
Phase::Saving { frames, seen } => {
let busy = !capturing.is_empty();
+101 -10
View File
@@ -44,11 +44,27 @@ impl Plugin for PhaseProbePlugin {
// mesh instead of one placement's batches (B16: a pool that is emitted, meshed, visible
// and textured, whose pixels never change — "was its mesh ever SUBMITTED to a phase?" is
// exactly the gap between the sim-side depth dump and the framebuffer).
let particles = raw.as_deref().is_some_and(|v| v.trim() == "particles");
let object = raw.as_deref().and_then(|v| v.trim().parse::<u32>().ok());
// `WOW_PHASE=particles[:<bone>,…]` — the bone list is the ARMING key, not a filter: the
// watch list is collected once and then held, and a world scene is full of other emitters
// (torches, braziers) that go live long before a fixture subject spawns. Naming the bones
// the question is about (`60,61` = the voidwalker's eyes; `44,45,46` = the wisp's three
// streamers) makes the probe wait for THAT effect instead of latching onto the first live
// one it sees. Ribbon trails arm and report on the same bone key as emitters.
let spec = raw.as_deref().map(str::trim);
let particles = spec.is_some_and(|v| v == "particles" || v.starts_with("particles:"));
let bones: Vec<u16> = spec
.and_then(|v| v.strip_prefix("particles:"))
.map(|list| {
list.split(',')
.filter_map(|b| b.trim().parse().ok())
.collect()
})
.unwrap_or_default();
let object = spec.and_then(|v| v.parse::<u32>().ok());
if !particles && object.is_none() {
warn!(
"phase: WOW_PHASE wants a placement uniqueId (e.g. 235256) or `particles` — inert"
"phase: WOW_PHASE wants a placement uniqueId (e.g. 235256) or \
`particles[:<bone>,…]` — inert"
);
return;
}
@@ -65,6 +81,7 @@ impl Plugin for PhaseProbePlugin {
app.insert_resource(PhaseWatch {
object,
particles,
bones,
at,
count,
batches: Vec::new(),
@@ -90,6 +107,8 @@ struct PhaseWatch {
object: u32,
/// `WOW_PHASE=particles`: follow every live particle emitter's quad mesh instead.
particles: bool,
/// `WOW_PHASE=particles:<bone>,…`: don't arm until every one of these emitter bones is live.
bones: Vec<u16>,
at: f32,
count: u32,
/// The watched batches: main-world entity, its WMO batch order, how it draws, and (particles
@@ -159,18 +178,38 @@ fn collect_batches(
watch.armed = true;
}
/// The `particles` mode's collector: every LIVE particle emitter's own quad-mesh entity, labeled
/// by its emitter bone + blend (the same identity the sim-side depth dump prints, so a phase line
/// and a dump line name the same pool). One-shot at the window like [`collect_batches`].
/// The `particles` mode's collector: every LIVE **effect** mesh — particle-emitter quad clouds and
/// ribbon trails alike — labeled by its emitter bone + blend (the same identity the sim-side depth
/// dump prints, so a phase line and a dump line name the same pool) — **and every model batch in
/// the scene alongside them**.
///
/// Ribbons are here because they are the same kind of thing under the same law: a trail is one of
/// its owner model's emitters, drawn in that model's own post-batch bracket, and in our renderer it
/// is another `NoFrustumCulling` mesh in the one distance-sorted transparent list. A probe that saw
/// only quads could say "the eye glow is ordered right" while a wisp's streamers were still
/// interleaved with the wisp.
///
/// The emitters alone answer "was it submitted?". They cannot answer B16's actual question, which
/// is *relative*: a particle quad and its own model's blend batches share one `Transparent3d` list
/// sorted back-to-front, so which of the two draws last is what decides whether the eyes survive.
/// That is only readable if both sides are in the same report, on the same frame, with the sort
/// distances that produced the order — so this list holds both, and the batch column says which.
///
/// One-shot like [`collect_batches`], but gated on a live emitter rather than the clock alone: in
/// a fixture capture the subject spawns after streaming settles, so a fixed `WOW_PHASE_AT` either
/// fires before it exists or after the shot. Retrying until the pool is live pins the window to
/// the subject instead of to a wall-clock guess.
fn collect_emitters(
mut watch: ResMut<PhaseWatch>,
time: Res<Time>,
emitters: Query<(Entity, &crate::particles::ParticleEmitter, &Mesh3d)>,
trails: Query<(Entity, &crate::ribbons::RibbonTrail, &GlobalTransform)>,
parts: Query<(Entity, &crate::debug_panel::ModelPart, &GlobalTransform)>,
) {
if !watch.particles || watch.armed || time.elapsed_secs() < watch.at {
return;
}
let found: Vec<WatchedBatch> = emitters
let mut found: Vec<WatchedBatch> = emitters
.iter()
.filter(|(_, e, _)| e.live() > 0)
.map(|(ent, e, mesh)| {
@@ -178,16 +217,61 @@ fn collect_emitters(
(
ent,
i32::from(def.bone),
format!("{:?}(pool {})", def.blend, e.live()),
format!("EMIT {:?}(pool {})", def.blend, e.live()),
Some((mesh.0.id(), e.texture().id())),
)
})
.collect();
// A trail with fewer than two edges builds an empty mesh, so it is submitted but draws
// nothing — the same "live" bar the emitters use (`live() > 0`).
found.extend(trails.iter().filter_map(|(ent, t, _)| {
let (blend, edges) = t.shape();
(edges > 0).then(|| {
(
ent,
i32::from(t.bone()),
format!("RIBB {blend:?}(edges {edges})"),
None,
)
})
}));
if found.is_empty() {
return;
}
// The arming key (see the plugin's parse): every named bone must be live, or this frame is too
// early and the list would freeze around the wrong pool.
if !watch
.bones
.iter()
.all(|b| found.iter().any(|&(_, bone, _, _)| bone == i32::from(*b)))
{
return;
}
// The model side of the comparison, scoped to the batches that could actually overlap this
// pool on screen. A world scene holds tens of thousands of model batches and all of them are
// in the same sorted list; the ones that matter are the ones standing where the cloud is, so
// the scope is a radius around the watched anchors rather than "every model in the world".
const NEAR: f32 = 6.0;
let anchors: Vec<Vec3> = emitters
.iter()
.filter(|(_, e, _)| e.live() > 0)
.map(|(_, e, _)| e.anchor_world())
// A trail's entity translation IS its sort point (the sim writes the live head node
// there), so its `GlobalTransform` is the anchor without an accessor of its own.
.chain(trails.iter().map(|(_, _, gt)| gt.translation()))
.collect();
found.extend(
parts
.iter()
.filter(|(_, _, gt)| {
anchors
.iter()
.any(|a| gt.translation().distance(*a) <= NEAR)
})
.map(|(ent, part, _)| (ent, -1, format!("PART {:?}", part.blend), None)),
);
info!(
"phase: watching {} live emitter meshes for {} frames",
"phase: watching {} live effect meshes + model batches for {} frames",
found.len(),
watch.count
);
@@ -268,7 +352,14 @@ fn report_phases(
}
for phase in transparent.values() {
if let Some(pos) = phase.items.iter().position(|item| item.entity.1 == main) {
found.push(format!("Transparent3d @{pos}"));
// The sort key itself, not just the slot: `Transparent3d` orders on view-space z +
// the material's depth bias, ascending = farthest first (`crate::sky_order`). Two
// slots tell you WHICH drew last; the two distances tell you WHY, and whether the
// gap is a real spatial one or a tie the sort broke arbitrarily.
found.push(format!(
"Transparent3d @{pos} d {:.3}",
phase.items[pos].distance
));
}
}
// "NOT SUBMITTED" is the whole point of the instrument: a batch that reached no phase was
+112 -2
View File
@@ -473,7 +473,7 @@ fn drive_live_fps(
return;
}
if let Ok(mut w) = windows.single_mut() {
w.present_mode = bevy::window::PresentMode::AutoNoVsync;
w.present_mode = super::probe_uncap_mode();
}
info!(
"live-fps: in-world + settled; vsync off, warming {PROBE_WARMUP_FRAMES} frames{}",
@@ -518,6 +518,12 @@ fn drive_live_fps(
.single()
.map(|w| (w.physical_width(), w.physical_height()))
.unwrap_or((0, 0));
// The present mode actually measured under — an uncap that silently rails (0362) is
// only diagnosable if the line says what was asked for.
let present = windows
.single()
.map(|w| format!(" present={:?}", w.present_mode))
.unwrap_or_default();
// CPU cost per frame across every thread — the load-robust half of the measurement
// (`perf::process_cpu_secs`), and directly comparable with a reporter's CPU %.
let cpu = match (probe.cpu_at_start, crate::perf::process_cpu_secs()) {
@@ -540,7 +546,7 @@ fn drive_live_fps(
_ => String::new(),
};
println!(
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} streamed={} parked={} entities={} px={}x{}{cpu}{at_pin}",
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} streamed={} parked={} entities={} px={}x{}{cpu}{present}{at_pin}",
v.len(),
at(0.50),
at(0.95),
@@ -826,3 +832,107 @@ fn fire_node_probe(world: &mut World) {
);
}
}
/// The entity census (`WOW_ENTITY_CENSUS=<secs>`, REAL seconds): once, `t` seconds in, print one
/// line per live archetype — entity count plus its signal components, largest first — and a machine-readable
/// summary. The "what IS the entity count made of" instrument: the standing HUD reads tens of
/// thousands of entities, and every per-frame cost that scales with *residency* (0362's
/// change-tick sweeps, transform propagation, render extraction) is only attributable once
/// residency itself has names. Born with the cost-ledger campaign.
pub(crate) struct EntityCensusPlugin;
impl Plugin for EntityCensusPlugin {
fn build(&self, app: &mut App) {
let at = std::env::var("WOW_ENTITY_CENSUS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10.0);
app.insert_resource(EntityCensus { at, fired: false })
.add_systems(Update, fire_entity_census);
}
}
/// [`EntityCensusPlugin`] state: the fire time and the once-latch.
#[derive(Resource)]
struct EntityCensus {
at: f32,
fired: bool,
}
/// Archetype lines the census prints; everything smaller folds into the summary's `other_n`.
const ENTITY_CENSUS_ROWS: usize = 60;
/// Signal components shown per archetype line — enough to name what the entities are without
/// drowning the line in a 30-component render archetype.
const ENTITY_CENSUS_COMPS: usize = 14;
fn fire_entity_census(world: &mut World) {
{
// REAL seconds, not virtual: the census is timed to compose with `WOW_LIVE_FPS_AT`
// (also real), and virtual time lags real by the load stalls — a virtual-timed one-shot
// scheduled "just before sampling" fires after the probe has already exited.
let time = world.resource::<Time<bevy::time::Real>>().elapsed_secs();
let probe = world.resource::<EntityCensus>();
if probe.fired || time < probe.at {
return;
}
}
world.resource_mut::<EntityCensus>().fired = true;
let components = world.components();
let mut rows: Vec<(usize, String)> = world
.archetypes()
.iter()
.filter(|a| !a.is_empty())
.map(|a| {
let full: Vec<String> = a
.components()
.iter()
.filter_map(|id| components.get_info(*id))
.map(|c| c.name().shortname().to_string())
.collect();
let signal: Vec<String> = full
.iter()
.filter(|n| {
// Drop the ubiquitous plumbing components — the signal is the rest.
!matches!(
n.as_str(),
"Transform"
| "GlobalTransform"
| "Visibility"
| "InheritedVisibility"
| "ViewVisibility"
| "ChildOf"
| "Children"
)
})
.cloned()
.collect();
// A bare transform node has no signal left after the filter — and two such
// archetypes differing only in plumbing (Children vs not) would print as identical
// rows. For those, the plumbing IS the signal: print the full list.
let names = if signal.len() <= 1 { full } else { signal };
let shown = names.len().min(ENTITY_CENSUS_COMPS);
let more = names.len() - shown;
let mut comps = names[..shown].join(", ");
if more > 0 {
comps.push_str(&format!(" +{more}"));
}
(a.len() as usize, comps)
})
.collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.0));
let (total_arch, total_n) = (rows.len(), rows.iter().map(|r| r.0).sum::<usize>());
let other_n = rows
.iter()
.skip(ENTITY_CENSUS_ROWS)
.map(|r| r.0)
.sum::<usize>();
for (n, comps) in rows.iter().take(ENTITY_CENSUS_ROWS) {
println!("ENTITY_CENSUS_ARCH n={n} comps=[{comps}]");
}
println!(
"ENTITY_CENSUS total={total_n} archetypes={total_arch} \
rows={} other_n={other_n}",
rows.len().min(ENTITY_CENSUS_ROWS),
);
}
+57 -10
View File
@@ -1,10 +1,20 @@
//! The panel's **World** section — the `.gps` of the client: who you are, which map/zone you're
//! in (and whether the zone system calls it indoors), the exact WoW-space position + facing, and
//! the terrain stream's tile residency. Pure readout except one affordance: **copy `.go xyz`** —
//! a click puts the vmangos teleport line for the current spot on the clipboard
//! (`.go xyz x y z [mapid]`, verified against vmangos `HandleGoXYZCommand`), so "it looks wrong
//! *here*" becomes a pasteable coordinate for the headless probes (`WOW_PROBE_CHAT`, live-shot
//! runs) and the FPS-journal loop closes without hand-copying numbers.
//! the terrain stream's tile residency. Pure readout except two affordances:
//!
//! - **copy `.go xyz`** — a click puts the vmangos teleport line for the current spot on the
//! clipboard (`.go xyz x y z [mapid]`, verified against vmangos `HandleGoXYZCommand`), so "it
//! looks wrong *here*" becomes a pasteable coordinate for the headless probes
//! (`WOW_PROBE_CHAT`, live-shot runs) and the FPS-journal loop closes without hand-copying
//! numbers.
//! - **land here** — the button half of [`crate::player::land`] (whose chord is `Ctrl`+`Cmd`+`G`),
//! shown only while free-flying.
//!
//! **While free-flying, the spot is the CAMERA's** — the section gains a `camera` line and the
//! copy button switches to it. The subject here is "where you are", and detached that is where you
//! flew to; the frozen avatar is not it. Copying the body's coordinates was the old behaviour, and
//! it was wrong at exactly the moment the button is worth pressing: you fly out to a spot
//! precisely *because* you want its number.
use benilla_assets::coords::bevy_to_wow;
use benilla_formats::world_to_tile;
@@ -13,12 +23,14 @@ use bevy::prelude::*;
use bevy_egui::egui;
use super::OVERLAY_TEXT_DIM;
use crate::player::land::LandHere;
/// The World section's read side, bundled (the 16-param ceiling of `debug_panel_ui`): where the
/// player is — map, zone leaf, indoor claim, tile residency — and who they are (guid + the
/// ask-once name cache the unit frames use).
/// ask-once name cache the unit frames use). Plus the free-fly pair: the camera's own transform
/// (the detached spot) and the land-here ask the button writes.
#[derive(SystemParam)]
pub(super) struct WorldReadout<'w> {
pub(super) struct WorldReadout<'w, 's> {
player: Option<Res<'w, crate::player::Player>>,
map: Option<Res<'w, crate::world_map::CurrentMap>>,
catalog: Option<Res<'w, crate::world_map::MapCatalogRes>>,
@@ -29,6 +41,8 @@ pub(super) struct WorldReadout<'w> {
self_guid: Res<'w, crate::net::SelfGuid>,
names: ResMut<'w, crate::names::NameCache>,
net_commands: Res<'w, crate::net::NetCommands>,
camera: Query<'w, 's, &'static Transform, With<crate::player::WorldCamera>>,
land: MessageWriter<'w, LandHere>,
}
/// Render the section: readout lines top-down (who → map → zone → position → tiles), the copy
@@ -89,6 +103,7 @@ pub(super) fn world_section(ui: &mut egui::Ui, world: &mut WorldReadout) {
};
let [x, y, z] = bevy_to_wow(player.pos);
let o = player.facing().rem_euclid(std::f32::consts::TAU);
let detached = player.detached;
ui.add_space(4.0);
ui.label(egui::RichText::new(format!("{x:.1} {y:.1} {z:.1}")).monospace());
ui.label(
@@ -102,13 +117,45 @@ pub(super) fn world_section(ui: &mut egui::Ui, world: &mut WorldReadout) {
.color(OVERLAY_TEXT_DIM),
);
// Free-flying: the camera is the spot (the lines above are the frozen body, kept — knowing
// where you left it is exactly what you fly back to). This line, and the copy/land pair below,
// all speak the camera's coordinates while detached.
let detached_at = detached
.then(|| {
world
.camera
.single()
.ok()
.map(|c| bevy_to_wow(c.translation))
})
.flatten();
if let Some([cx, cy, cz]) = detached_at {
ui.add_space(4.0);
ui.label(
egui::RichText::new(format!("camera {cx:.1} {cy:.1} {cz:.1}"))
.monospace()
.color(OVERLAY_TEXT_DIM),
);
}
// The teleport line: `.go xyz x y z [mapid]` (vmangos argument order). Copied, not shown —
// the readout above already displays every number.
if ui.button("copy .go xyz").clicked() {
let [gx, gy, gz] = detached_at.unwrap_or([x, y, z]);
let copy_label = if detached_at.is_some() {
"copy .go xyz (camera)"
} else {
"copy .go xyz"
};
if ui.button(copy_label).clicked() {
let line = match map_id {
Some(id) => format!(".go xyz {x:.2} {y:.2} {z:.2} {id}"),
None => format!(".go xyz {x:.2} {y:.2} {z:.2}"),
Some(id) => format!(".go xyz {gx:.2} {gy:.2} {gz:.2} {id}"),
None => format!(".go xyz {gx:.2} {gy:.2} {gz:.2}"),
};
ui.ctx().copy_text(line);
}
// Land here — the button half of the `Ctrl`+`Cmd`+`G` chord. Only while detached, because
// attached the camera sits behind the avatar and "land at the camera" means a step backwards.
if detached_at.is_some() && ui.button("land here").clicked() {
world.land.write(LandHere);
}
}
@@ -949,6 +949,7 @@ pub(super) fn attach_entity_visuals(
rb,
owner,
use_pivot,
placement.scale.max_element(),
None,
);
}
+17 -5
View File
@@ -725,6 +725,10 @@ pub(super) fn attach_held_items(
Entity,
Option<&UnitAppearFade>,
Option<&crate::interior::BodyBakeCenter>,
// The unit root's own transform — its scale is the wire `NetEntity::scale` the streamer
// writes (`entities::attach`), and the held effects' draw-order rung is measured in the
// world yards that scale produces.
Option<&Transform>,
)>,
held: Option<Res<ItemDisplays>>,
time: Res<Time>,
@@ -736,7 +740,7 @@ pub(super) fn attach_held_items(
return;
};
let now = time.elapsed_secs();
for (items, bones, attached, entity, unit_fade, body_center) in &mut units {
for (items, bones, attached, entity, unit_fade, body_center, unit_tf) in &mut units {
// A held item / helm / shoulder resolves and spawns asynchronously (a template round trip, a
// model load) — often *after* the body has already armed its appear-fade (decision 0032 is a
// per-unit property: the reference fades the whole unit, attachments included, as one). Read
@@ -932,9 +936,16 @@ pub(super) fn attach_held_items(
// so the rest pose applies and no pivot rebase is needed — the flame burns at its
// authored spot (the torch tip) and follows the hand through the swing. Free entities:
// they self-despawn with `root` via the owner contract (gear change, unit despawn).
// The spawn transform only seeds the flicker RNG (the owner overwrites it every frame)
// — root's entity bits de-sync two torch-bearers standing side by side.
let rng_seed = Transform::from_translation(Vec3::splat(root.to_bits() as f32));
// The spawn transform does two jobs, and neither is placing the emitter (the owner
// overwrites the position every frame): its TRANSLATION seeds the flicker RNG —
// root's entity bits de-sync two torch-bearers standing side by side — and its SCALE
// is the wearer's, which is what the effects' draw-order rung is measured in
// (`particles::owner_last_bias`). An item on a twice-size wielder reaches twice as
// far from its own origin, so its rung has to grow with it; reading the scale off a
// transform built as a bare RNG seed silently pinned every held effect at 1×.
let unit_scale = unit_tf.map_or(1.0, |t| t.scale.max_element());
let spawn_tf = Transform::from_translation(Vec3::splat(root.to_bits() as f32))
.with_scale(Vec3::splat(unit_scale));
for em in &dm.emitters {
crate::particles::spawn_emitter(
&mut commands,
@@ -942,7 +953,7 @@ pub(super) fn attach_held_items(
&mut particle_materials,
&light,
em,
rng_seed,
spawn_tf,
Some((root, [0.0; 3])),
Some(root), // a held item is an attached model — the flame fans with the swing
Some(root), // whole-model owner: anchor == owner, the plain carry
@@ -972,6 +983,7 @@ pub(super) fn attach_held_items(
rb,
root,
false,
unit_scale,
Some(0),
);
}
+54 -1
View File
@@ -68,6 +68,10 @@ const ATTACH_FALLBACKS: [u16; 2] = [0xf, 0x13];
/// fallback and kin) — long enough to read as a flash, short enough never to linger.
const FALLBACK_SPAN: f32 = 1.0;
/// The `fxview` unit lane's synthetic guid (`WOW_FX_DISPLAY`) — a high-word `0xF130` creature guid
/// like the wire's, in a serverless capture where nothing else claims one.
const FXVIEW_UNIT_GUID: u64 = (0xF130u64 << 48) | 0xFC0FEE;
/// How long a self-terminating instance may wait unspawned (model/skeleton never loading — the
/// cube-fallback caster) before it is dropped instead of pending forever.
const PENDING_TIMEOUT: f32 = 10.0;
@@ -199,6 +203,43 @@ pub(crate) fn drive_fx_view(
let Some(light) = light else { return };
let root = *state.root.get_or_insert_with(|| {
let mut pos = benilla_assets::coords::wow_to_bevy(crate::capture::FXVIEW_POS);
// `WOW_FX_DISPLAY`: the UNIT lane. Spawn the live component set a streamed creature gets
// (`net::apply`'s, the same one the `vplates` fixture's wolf uses) and let the ordinary
// unit pipeline build the model — so what this shoots is a creature, not a model, and
// every unit-only term (tag alpha, the fade gate, anim LOD, the emitters' sequence host)
// is in the picture. Always seated on the terrain: a creature stands on the ground.
if let Some(display) = req.display {
if let Some(hit) = spatial.cast_ray(
pos,
Dir3::NEG_Y,
500.0,
true,
&crate::collision::player_query_filter(),
) {
pos.y -= hit.distance;
}
return commands
.spawn((
crate::net::Guid(FXVIEW_UNIT_GUID),
crate::net::NetEntity {
kind: benilla_protocol::EntityKind::Unit,
display_id: Some(display),
scale: req.scale,
},
crate::net::ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(
&[
(22, 100), // UNIT_FIELD_HEALTH
(28, 100), // UNIT_FIELD_MAXHEALTH
(34, 60), // UNIT_FIELD_LEVEL
(35, 35), // UNIT_FIELD_FACTIONTEMPLATE — friendly, so no combat pose
],
)),
Transform::from_translation(pos)
.with_rotation(Quat::from_rotation_y(req.yaw_deg.to_radians())),
Visibility::default(),
))
.id();
}
// `WOW_FX_GROUND=1`: seat the fixture ON the terrain — a ground-anchored effect's flat
// quads render as projected surface decals and need ground inside their vertical slab.
// The scene settled before arming, so the streamed tile's collider is there to hit.
@@ -237,7 +278,8 @@ pub(crate) fn drive_fx_view(
// instance dies at exactly one pass of sequence 0 and its emitters DRAIN — the fixture
// mirrors it so a capture past the span shows the truth. `WOW_FX_HOLD=1` previews a
// persistent hold instead (reaped by its spell edge in game, so no clock here).
if !req.hold && !state.expired {
// The one-pass reap is an effect-instance rule; a unit stands there.
if !req.hold && !state.expired && req.display.is_none() {
if let Some(at) = state.attached_at {
let span = fx
.models
@@ -251,6 +293,13 @@ pub(crate) fn drive_fx_view(
}
}
if state.attached_at.is_none() {
// The unit lane has no attach step — the unit pipeline builds the model on its own. Its
// "age" is therefore seconds since the unit appeared, which is what the animation phase is
// measured in.
if req.display.is_some() {
state.attached_at = Some(time.elapsed_secs());
return;
}
fx.models
.entry(req.model_path.clone())
.or_insert_with(|| DisplayModel {
@@ -484,6 +533,10 @@ pub(super) fn attach_effect_visuals(
rb,
owner,
use_pivot,
// An effect-model instance is armed unscaled — its emitters take the same
// `Transform::IDENTITY` placement a few lines up, so both of a model's effect
// families land on the same draw-order rung, which is the property that matters.
1.0,
played_anim,
);
}
+1
View File
@@ -344,6 +344,7 @@ pub(super) fn spawn_wmo_gameobject_props(
rb,
owner,
use_pivot,
placement.scale.max_element(),
None,
)
.is_some()
+8 -1
View File
@@ -687,7 +687,9 @@ fn main() {
// per frame, as a distance in yards — the link past submission that decides the pixel. Pair it
// with `WOW_PICK` at the same pixels to turn "what won" into "whose it was" (see
// `capture::DepthProbePlugin`).
if std::env::var("WOW_DEPTH").is_ok() {
// `WOW_DEPTH_QUADS=<bone>…` is the same readback taken at a particle quad's OWN pixels — the
// moving-subject form, which no hand-written pixel list can hold (see `capture::depth_probe`).
if std::env::var("WOW_DEPTH").is_ok() || std::env::var("WOW_DEPTH_QUADS").is_ok() {
app.add_plugins(capture::DepthProbePlugin);
}
// The bevy_ui node census — "who owns this rectangle" for UI outside the FrameXML quad pass
@@ -705,6 +707,11 @@ fn main() {
if std::env::var("WOW_PARTICLE_CENSUS").is_ok() {
app.add_plugins(capture::ParticleCensusPlugin);
}
// The entity census: `WOW_ENTITY_CENSUS=<secs>` prints per-archetype entity counts once —
// what the resident entity count is made of (see `capture::EntityCensusPlugin`).
if std::env::var("WOW_ENTITY_CENSUS").is_ok() {
app.add_plugins(capture::EntityCensusPlugin);
}
// The melee live probe: `WOW_PROBE=melee` auto-fights the nearest enemy so the dbg-trace
// sink can record the combat-text timeline (see `capture::ProbeMeleePlugin`).
if std::env::var("WOW_PROBE").as_deref() == Ok("melee") {
+33 -1
View File
@@ -90,7 +90,10 @@ pub(crate) fn zone_uv(rect: ZoneRect, wx: f32, wy: f32) -> (f32, f32) {
/// UV → world on a zone/continent rect (`0x4a7100` zone-mode, verbatim — including the binary's
/// quirk that BOTH axes lerp by the same `t` (its v input goes unused; the real callers consume
/// one axis per call). Returns (wx, wy).
#[allow(dead_code)] // transcribed law, held for its first consumer (phase 3's ping placement)
///
/// Its first consumer is the dev map-jump ([`crate::ui_world_map`]), which calls it exactly that
/// way — once for `wy` with the click's `u`, once for `wx` with its `v`. Used per-axis, this IS
/// the exact inverse of [`zone_uv`] (unlike the world-mode pair below).
pub(crate) fn zone_world(rect: ZoneRect, t: f32) -> (f32, f32) {
let wy = rect.left - (rect.left - rect.right) * t;
let wx = rect.top - (rect.top - rect.bottom) * t;
@@ -208,6 +211,35 @@ mod tests {
close(zone_world(ELWYNN, 1.0), (ELWYNN.bottom, ELWYNN.right));
}
/// Used ONE AXIS PER CALL — the way the reference's callers consume it, and the way the dev
/// map-jump inverts a click — `zone_world` is the exact inverse of [`zone_uv`]. This is the
/// claim the jump's landing accuracy rests on, so it is pinned rather than reasoned about:
/// a world point projects to UV, and that UV comes back to the same point.
#[test]
fn zone_world_per_axis_inverts_zone_uv() {
for (wx, wy) in [
(ELWYNN.top - 1.0, ELWYNN.left - 1.0),
(-9000.0, 100.0),
(-9450.0, 500.0),
(
(ELWYNN.top + ELWYNN.bottom) * 0.5,
(ELWYNN.left + ELWYNN.right) * 0.5,
),
] {
let (u, v) = zone_uv(ELWYNN, wx, wy);
assert!(u > 0.0 && v > 0.0, "fixture must be on the rect: {u} {v}");
// wy rides the u axis, wx the v axis — the axis swap zone_uv makes.
let (_, back_wy) = zone_world(ELWYNN, u);
let (back_wx, _) = zone_world(ELWYNN, v);
// Yards, not `close`'s 1e-4: these coordinates are ~10^4, so f32's ~7 digits leave
// millimetre-scale slop. A hundredth of a yard is inverse enough to teleport onto.
assert!(
(back_wx - wx).abs() < 0.01 && (back_wy - wy).abs() < 0.01,
"({back_wx}, {back_wy}) vs ({wx}, {wy})"
);
}
}
/// With scale = 1 the world-level pair IS inverse (the anomaly is scale-dependent — see
/// `world_click_world`'s doc); pin the transcription shape through a roundtrip.
#[test]
+92 -2
View File
@@ -94,6 +94,14 @@ struct Particle {
angvel: Vec3,
}
impl ParticleEmitter {
/// The rig bone this emitter is mounted on — the scope key the depth probes filter by
/// (`$WOW_DEPTH_QUADS`, `$WOW_PARTICLE_DEPTHDUMP_BONES`).
pub(crate) fn bone(&self) -> u16 {
self.def.bone
}
}
/// A spawned particle emitter: its parsed def + the placement that maps its local space to the world,
/// the live pool, the fractional emission accumulator, a per-emitter RNG, and the handle of the mesh we
/// rewrite each frame. Despawns with its placement (the entity joins the placement's entity list).
@@ -172,6 +180,12 @@ pub struct ParticleEmitter {
/// pre-arm frame reads that slot's opening pose). `None` ⇒ slot 0 (the doodad law).
seq: Option<usize>,
rng: u32,
/// The OWNER model's reach from its origin in **world** yards (the authored
/// [`ModelEmitter::owner_reach`] × the placement scale) — the draw-order rung this cloud and
/// every CHILD cloud of it is biased by, so both clear the owner's own transparent batches
/// (see [`owner_last_bias`]). Children carry the parent's, not their recursion model's: a
/// child draws at the parent's anchor, so it is the parent's owner it has to clear.
owner_reach: f32,
mesh: Handle<Mesh>,
/// The particle texture — also held here (not just on the material) so the sim can withhold drawing
/// until it's resident: a still-loading or failed-to-decode texture would otherwise flash the engine
@@ -406,6 +420,16 @@ impl ParticleEmitter {
&self.texture
}
/// The owner-last draw-order rung this emitter's effects take (see [`owner_last_bias`]).
///
/// Read by the MODEL-particle draw ([`model::update_model_particles`]), whose instances are
/// built from the geometry model's own batch materials and would otherwise sit at rung 0 — so
/// a 3-D particle would be painted over by its own sibling quad cloud. The reference draws all
/// of a model's emitters in the one bracket; sharing this number is what says so.
pub(super) fn owner_rung(&self) -> f32 {
owner_last_bias(self.owner_reach)
}
/// The cloud's live world anchor — the census probe's fallback distance subject for an emitter
/// with no [`EmitterFade`] (entity-owned: creatures, GameObjects, spell kits). A faded
/// emitter's distance is measured to its OWNER's sphere instead, because that sphere is what
@@ -572,7 +596,10 @@ pub fn spawn_emitter(
EmitClock::PinnedSeq(s) => (None, Some(s)),
EmitClock::Host(h) => (Some(h), None),
};
let material = particle_material(&def, &texture, materials, light);
// The owner's reach is model-local; the rung is a view-space distance, so it takes the
// placement scale with it (a scaled-up creature's batches spread proportionally).
let owner_reach = emitter.owner_reach * placement.scale.max_element();
let material = particle_material(&def, &texture, owner_reach, materials, light);
let mesh = meshes.add(blank_particle_mesh());
// Seed the RNG from the placement position so two campfires don't flicker in lockstep.
let t = placement.translation;
@@ -607,6 +634,7 @@ pub fn spawn_emitter(
host,
seq,
rng,
owner_reach,
mesh,
texture,
recursion: emitter.recursion.clone(),
@@ -630,6 +658,7 @@ pub fn spawn_emitter(
fn particle_material(
def: &ParticleEmitterDef,
texture: &Handle<Image>,
owner_reach: f32,
materials: &mut Assets<WowParticleMaterial>,
light: &SharedLightBuffer,
) -> Handle<WowParticleMaterial> {
@@ -651,6 +680,9 @@ fn particle_material(
unlit: true,
alpha_mode,
cull_mode: None, // billboards: never backface-cull
// The reference's "a model's particles draw after that model's batches" — see
// [`owner_last_bias`].
depth_bias: owner_last_bias(owner_reach),
..default()
},
extension: WowParticleExt {
@@ -661,6 +693,42 @@ fn particle_material(
})
}
/// The **owner-last** draw-order rung: how far to bias one of a model's EFFECTS past that model's
/// own transparent batches (`Transparent3d` sort key = view-space z **+ depth_bias**, ascending, so
/// a positive bias draws LATER — the sign law lives in [`crate::sky_order`]).
///
/// The reference draws a model's emitters in their own push/pop bracket (`0x70d8b0`) *after* that
/// model's batches, unconditionally — never interleaved with them. Bevy has one distance-sorted
/// transparent list, and an effect mesh carries `NoFrustumCulling`, which suppresses the `Aabb`
/// Bevy would otherwise sort by, so it sorts at its entity translation while each body batch sorts
/// at its own bind-pose AABB centre a yard or two up the model. Both measured shapes of that:
///
/// - **Quad clouds** sort at the owner's ORIGIN, so the order flips with camera ELEVATION. On the
/// voidwalker at 12 yd: at 0° the eye emitters land at transparent slots 324/325 with 5 of the 14
/// blend batches still behind them; at 35° they drop to slots 7/8 and **all 14** draw over them —
/// B16, the eye glow visible from below the eye horizon and gone from above (decision 0719).
/// - **Ribbon trails** sort at the live head node, which sits *inside* the owner, so they interleave
/// at every angle and the interleave MOVES. On the wisp at 6 yd, elevation 0°: its three streamers
/// land at slots 310/317/324 among the wisp's own 14 blend batches, 6 batches deep each, and which
/// batches are over which streamer changes frame to frame as the streamers whip (decision 0721).
///
/// `reach` is the owner's authored [`benilla_assets::ModelEmitter::owner_reach`] (or
/// [`benilla_assets::ModelRibbon::owner_reach`]) in **world** yards — the model-local bound
/// [`benilla_formats::m2_owner_reach`] measures, times the placement scale. View-space z is
/// 1-Lipschitz in world position and no owner batch sorts farther than `reach` from its origin, so
/// `z(origin) + reach ≥ z(any of its batch centres)`.
///
/// Every effect of one model must take the SAME rung, or the law is broken from the other side: the
/// reference draws a model's emitters in file order within the bracket, so a quad cloud on rung 4
/// beside a model-particle instance on rung 0 would paint over its own sibling.
///
/// The rounding, the ceiling and their reasons live with the shared implementation in
/// [`benilla_formats::owner_last_rung`] — the survey tools (`emdump`, `benilla-extract
/// fxordercensus`) print the same number this applies, and a rung computed twice drifts twice.
pub(crate) fn owner_last_bias(reach: f32) -> f32 {
benilla_formats::owner_last_rung(reach)
}
/// Wire pending CHILD emitters (wow-re `part-child-recursion.md`, VERIFIED): once a parent's
/// recursion model resolves, its own particle emitters — **capped at 4** (`0x7b5dfe`) — become
/// the parent's children, each with its private pool and draw entity. The reference wires at
@@ -680,6 +748,7 @@ pub(crate) fn wire_child_emitters(
continue;
};
let mut rng_seed = emitter.rng.rotate_left(7) | 1;
let parent_reach = emitter.owner_reach;
let children: Vec<ChildEmitter> = model
.emitters
.iter()
@@ -689,7 +758,9 @@ pub(crate) fn wire_child_emitters(
if em.def.lifespan <= 0.0 || em.def.timing.peak_rate() <= 0.0 {
return None;
}
let material = particle_material(&em.def, &texture, &mut materials, &light);
// The PARENT's rung, not the recursion model's (see `ParticleEmitter::owner_reach`).
let material =
particle_material(&em.def, &texture, parent_reach, &mut materials, &light);
let mesh = meshes.add(blank_particle_mesh());
let entity = commands
.spawn((
@@ -827,6 +898,25 @@ pub(crate) mod tests {
.in_draw_set(Vec3::ZERO, Vec3::NEG_Z, farclip, true)
}
/// **The owner-last rung, pinned** (decisions 0719/0721), through the wrapper the renderer
/// actually calls. The whole point of it is one inequality — the rung must be STRICTLY greater
/// than the owner's reach, or a batch centred at the edge of the model ties with the effect and
/// the draw order goes back to being whatever the queue emitted. The voidwalker (reach 3.666 yd
/// by the old vertex bound, 1.945 by the batch-centre one) is the measured case; the integer
/// reach is the one that would tie under a bare `ceil`.
#[test]
fn the_owner_last_rung_always_clears_the_owner() {
for reach in [0.0f32, 0.5, 1.0, 3.666, 4.0, 7.999, 12.0, 31.5] {
let rung = owner_last_bias(reach);
assert!(rung > reach, "rung {rung} must clear reach {reach}");
assert_eq!(rung, rung.trunc(), "rung {rung} must be a whole yard");
}
// A model too big for the ladder is capped rather than allowed to climb into
// `sky_order`'s rungs — it loses its own ordering, not the world's.
assert_eq!(owner_last_bias(500.0), 32.0);
assert_eq!(owner_last_bias(-1.0), 1.0);
}
/// **The B39 defect, pinned** (decision 0678). An owner bigger than `NEVER_FADE_RADIUS` never
/// distance-fades, so the fade term admits it at every distance — the far-clip term is the only
/// thing that stops its emitter. Reverting that term makes this test fail, which is the point:
+7 -4
View File
@@ -68,10 +68,13 @@ impl MaterialExtension for WowParticleExt {
// every particle quad, so "this effect doesn't draw" splits cleanly into *nothing is
// emitted* (the census's job — `WOW_PARTICLE_CENSUS` counts live particles) and *it is
// emitted, rasterized, and the depth buffer eats it*. Those two look identical on screen
// and want completely different fixes. It found B16: the voidwalker's eye glow is two
// emitters mounted flush with the head skin (a ray from the eye bone crosses the body
// mesh at 3–6 mm), which our own opaque body pass then depth-rejects — under this switch
// both eyes appear exactly where the artist put them.
// and want completely different fixes.
//
// Its most useful reading on B16 was a NEGATIVE one: the voidwalker's eye glow stays gone
// from above under this switch, and `WOW_DEPTH_QUADS` then measured 30–48% of each quad
// surviving the compare at every elevation. Depth was never what killed it — the owner's
// own blend batches were drawing over it (`particles::owner_last_bias`, decision 0719).
// An earlier note here read the switch the other way round; that reading is retracted.
if std::env::var_os("WOW_PARTICLE_NODEPTH").is_some() {
if let Some(ds) = descriptor.depth_stencil.as_mut() {
ds.depth_compare = bevy::render::render_resource::CompareFunction::Always;
+13
View File
@@ -65,6 +65,7 @@ pub(super) fn update_model_particles(
continue; // still loading — particles simulate meanwhile, nothing draws yet
};
// Grow the pool to the live count (bounded).
let rung = emitter.owner_rung();
let want = emitter.particles.len().min(MAX_INSTANCES);
while emitter.model_instances.len() < want {
let meshes = model
@@ -98,6 +99,18 @@ pub(super) fn update_model_particles(
false,
&light.0,
);
// The owner-last draw-order rung, stamped on after the fact: a 3-D model
// particle is one of its owner's emitters exactly like the quad cloud beside
// it, and the reference draws them in one bracket after that model's batches
// — so both must sit on the SAME rung or the cloud paints over the shards
// (`ParticleEmitter::owner_rung`). It goes here rather than through
// `model_material`, which is the shared M2-batch recipe: every batch in the
// world would have to carry a particle-only argument and a cache-key field to
// move one number. These instance materials are per-instance throwaways
// (their `tint` is already mutated every frame), so nothing else sees it.
if let Some(m) = materials.get_mut(&material) {
m.base.depth_bias = rung;
}
let entity = commands
.spawn((
Mesh3d(sub.mesh.clone()),
+11 -4
View File
@@ -236,9 +236,15 @@ pub(super) fn simulate_particles(
let (_, cam_rot, _) = cam_tf.to_scale_rotation_translation();
let cam_right = cam_rot * Vec3::X;
let cam_up = cam_rot * Vec3::Y;
let face_normal = cam_up.cross(cam_right).normalize_or_zero(); // toward the camera
// The far-clip wall's axis — the SAME forward `debug_panel::visibility` measures the owner
// doodad's own cull along, so an emitter and its owner cross the wall together.
// Toward the camera: a camera-facing billboard's normal is the camera's BACKWARD axis, and
// `right × up` = X × Y = +Z local, which is backward in Bevy (a camera looks down −Z). The
// reverse product — what this used to be, under a comment claiming "toward the camera" —
// points down the view direction instead. Inert either way today (`wow_particle.wgsl` reads no
// normal, and the material is unlit + never backface-culled), so this buys honesty in the
// vertex data rather than a pixel; a lit particle lane would have inherited the sign error.
let face_normal = cam_right.cross(cam_up).normalize_or_zero();
// The far-clip wall's axis — the SAME forward `debug_panel::visibility` measures the owner
// doodad's own cull along, so an emitter and its owner cross the wall together.
let cam_fwd = Vec3::from(cam_tf.forward());
// `$WOW_PARTICLE_DEPTHDUMP` (B16): is this a dump frame? Decided once per run.
let dump_frame = super::depthdump::frame(time.elapsed_secs(), &mut dump_count);
@@ -263,7 +269,7 @@ pub(super) fn simulate_particles(
tf.translation(),
right,
up,
up.cross(right).normalize_or_zero(),
right.cross(up).normalize_or_zero(), // toward the booth camera — see above
)
}
None => (cam_pos, cam_right, cam_up, face_normal),
@@ -396,6 +402,7 @@ pub(super) fn simulate_particles(
host,
seq,
rng,
owner_reach: _, // a draw-order rung, baked into the material at spawn
mesh,
texture,
recursion: _,
+301 -14
View File
@@ -5,12 +5,18 @@
//! Bevy's diagnostics (frame time / FPS, entity count, render-pass timing), keeps a rolling window of
//! recent real frame durations, and draws a **minimal, always-on FPS pill** (top-center) that you
//! **click to expand** into the full readout; **Ctrl+Cmd+P** toggles the whole HUD. Expanded, it shows:
//! - FPS + last frame ms (red when over budget),
//! - FPS + last frame ms,
//! - **p50 / p99 / worst** frame ms over the window (worst-frame is the metric that matters, not the
//! average — averages hide the hitch you feel),
//! - frames-over-budget count/%,
//! - **dropped** frames (synced: intervals actually missed) / **over-budget** frames (uncapped) —
//! synced wall time rails at the display's present grant, so the two readings mean different things,
//! - **cpu ms/frame** — process CPU, all threads: the cost meter the rail *can't* fool, the HUD twin
//! of the probes' `cpu_ms` (decision 0711),
//! - a frame-time graph with the budget line,
//! - a **VSync toggle** — the cheap "uncap and see if we have headroom" CPU-vs-GPU test.
//! - a **VSync toggle** — the "uncap and see" headroom test. Trust it only when it moves: the uncap
//! is `AutoNoVsync` (explicit `Immediate` rails AND takes 1 s `nextDrawable` stalls on Metal —
//! measured, see `capture::probe_uncap_mode`), and a macOS power state can withhold the >60 grant
//! entirely (0362), in which case no present mode uncaps and `cpu ms` is the number to read.
//!
//! The budget is a **60 fps floor = 16.7 ms; no frame should exceed it.** Deep per-system attribution
//! is via Tracy (`cargo run --features tracy`) — the `info_span!` markers on the hot systems feed it.
@@ -45,6 +51,22 @@ const SAMPLE_WINDOW: usize = 300;
/// 16.7 ms budget so it only fires on real stalls (load bursts), not normal over-budget frames.
const HITCH_LOG_MS: f32 = 250.0;
/// Synced, wall frame time rails at the display's present interval and jitters ±1 ms around it, so
/// counting frames over the 16.7 ms budget mostly counts jitter (a perfectly healthy 60 Hz rail
/// reads "48% over budget"). What a synced player *feels* is a missed interval: 1.5× budget (25 ms)
/// sits past any rail jitter and under the doubled frame (33 ms), so the synced HUD counts
/// **dropped** frames against this instead.
const DROPPED_FACTOR: f32 = 1.5;
/// Does this present mode sync to the display? Synced, the HUD's wall-clock numbers measure the
/// present grant, not our cost — the stats lines switch meaning on it.
fn synced_mode(mode: PresentMode) -> bool {
!matches!(
mode,
PresentMode::AutoNoVsync | PresentMode::Immediate | PresentMode::Mailbox
)
}
pub struct PerfPlugin;
impl Plugin for PerfPlugin {
@@ -65,6 +87,8 @@ impl Plugin for PerfPlugin {
(toggle_hud, sample_frame_time),
)
.add_systems(EguiPrimaryContextPass, perf_hud_ui);
#[cfg(target_os = "macos")]
stall_sample::plugin(app);
}
}
@@ -72,12 +96,22 @@ impl Plugin for PerfPlugin {
#[derive(Resource)]
struct FrameStats {
samples: VecDeque<f32>,
/// Per-frame process-CPU deltas (ms, **user+system across every thread** —
/// [`process_cpu_secs`]), same window. A blocked present-wait consumes no CPU, so this is the
/// frame-cost meter vsync cannot rail: the HUD twin of the probes' `cpu_ms` (decision 0711),
/// directly comparable with a reporter's "N % CPU".
cpu_samples: VecDeque<f32>,
/// [`process_cpu_secs`] at the previous sample, for the per-frame delta. `None` until the
/// first frame (and forever on platforms where `getrusage` is unavailable).
prev_cpu_secs: Option<f64>,
}
impl Default for FrameStats {
fn default() -> Self {
Self {
samples: VecDeque::with_capacity(SAMPLE_WINDOW),
cpu_samples: VecDeque::with_capacity(SAMPLE_WINDOW),
prev_cpu_secs: None,
}
}
}
@@ -90,6 +124,19 @@ impl FrameStats {
self.samples.push_back(ms);
}
fn push_cpu(&mut self, ms: f32) {
if self.cpu_samples.len() == SAMPLE_WINDOW {
self.cpu_samples.pop_front();
}
self.cpu_samples.push_back(ms);
}
/// Windowed mean CPU ms/frame, all threads. `None` where the platform can't answer.
fn cpu_ms(&self) -> Option<f32> {
(!self.cpu_samples.is_empty())
.then(|| self.cpu_samples.iter().sum::<f32>() / self.cpu_samples.len() as f32)
}
fn last(&self) -> f32 {
self.samples.back().copied().unwrap_or(0.0)
}
@@ -117,13 +164,9 @@ impl FrameStats {
}
}
/// `(count, fraction)` of windowed frames that blew the budget.
fn over_budget(&self) -> (usize, f32) {
let n = self
.samples
.iter()
.filter(|&&ms| ms > FRAME_BUDGET_MS)
.count();
/// `(count, fraction)` of windowed frames above `threshold_ms`.
fn frames_over(&self, threshold_ms: f32) -> (usize, f32) {
let n = self.samples.iter().filter(|&&ms| ms > threshold_ms).count();
let frac = if self.samples.is_empty() {
0.0
} else {
@@ -165,6 +208,11 @@ fn toggle_hud(keys: Res<ButtonInput<KeyCode>>, mut hud: ResMut<PerfHud>) {
fn sample_frame_time(time: Res<Time<Real>>, mut stats: ResMut<FrameStats>) {
let ms = time.delta_secs() * 1000.0;
stats.push(ms);
let cpu = process_cpu_secs();
if let (Some(prev), Some(now)) = (stats.prev_cpu_secs, cpu) {
stats.push_cpu(((now - prev) * 1000.0) as f32);
}
stats.prev_cpu_secs = cpu;
// Log hard hitches so a load freeze is attributable from the log alone (one big stall vs many
// medium ones, and roughly when). The first frame's delta is the startup gap, not a hitch.
if ms > HITCH_LOG_MS && stats.samples.len() > 1 {
@@ -187,6 +235,16 @@ fn perf_hud_ui(
let fps = stats.fps();
let last = stats.last();
let synced = windows
.single()
.map(|w| synced_mode(w.present_mode))
.unwrap_or(true);
// Synced, a small over is rail jitter, not cost — red only past the dropped threshold there.
let red_above = if synced {
FRAME_BUDGET_MS * DROPPED_FACTOR
} else {
FRAME_BUDGET_MS
};
let red = egui::Color32::from_rgb(240, 120, 120);
let green = egui::Color32::from_rgb(140, 220, 140);
@@ -234,14 +292,14 @@ fn perf_hud_ui(
ui.separator();
let (p50, p99, max) = stats.percentiles();
let (over_n, over_frac) = stats.over_budget();
let (over_n, over_frac) = stats.frames_over(red_above);
let win_len = stats.samples.len();
let entities = diagnostics
.get(&EntityCountDiagnosticsPlugin::ENTITY_COUNT)
.and_then(|d| d.value())
.unwrap_or(0.0);
let last_col = if last > FRAME_BUDGET_MS { red } else { green };
let last_col = if last > red_above { red } else { green };
ui.colored_label(last_col, format!("last {last:>6.2} ms"));
ui.label(
egui::RichText::new(format!("budget {FRAME_BUDGET_MS:.2} ms · 60 fps floor"))
@@ -255,13 +313,39 @@ fn perf_hud_ui(
} else {
OVERLAY_TEXT_DIM
};
// Synced: "dropped" = missed present intervals (> DROPPED_FACTOR × budget) — the felt
// metric; wall time can't say more while it rails at the grant. Uncapped: the honest
// over-budget count against the 16.7 floor.
ui.colored_label(
ob_col,
format!(
"over budget {over_n}/{win_len} ({:.0}%)",
"{} {over_n}/{win_len} ({:.0}%)",
if synced { "dropped" } else { "over budget" },
over_frac * 100.0
),
);
)
.on_hover_text(if synced {
"frames past 1.5× budget — a missed display interval; \
small overs while synced are present-grant jitter, not cost"
} else {
"frames past the 16.7 ms budget (uncapped: wall time ≈ real frame cost)"
});
// The vsync-immune cost meter: process CPU per frame, every thread summed (the probes'
// `cpu_ms`, decision 0711). ~6 ms is the 1.12.1 reference client's whole-scene cost —
// the long-term bar.
if let Some(cpu) = stats.cpu_ms() {
let mean_ms = if fps > 0.0 { 1000.0 / fps } else { 0.0 };
let pct = if mean_ms > 0.0 {
cpu / mean_ms * 100.0
} else {
0.0
};
ui.label(format!("cpu {cpu:>6.2} ms · {pct:.0}% (all threads)"))
.on_hover_text(
"process CPU consumed per frame — measures our work, not the display's \
present grant; comparable with the probes' cpu_ms / a reporter's CPU %",
);
}
ui.label(format!("entities {entities:.0}"));
frame_graph(ui, &stats);
@@ -284,6 +368,8 @@ fn perf_hud_ui(
)
.changed()
{
// AutoNoVsync, never Immediate: on Metal, explicit Immediate both rails and
// takes ~1 s nextDrawable stalls (measured — `capture::probe_uncap_mode`).
window.present_mode = if vsync {
PresentMode::AutoVsync
} else {
@@ -437,3 +523,204 @@ pub(crate) fn process_cpu_secs() -> Option<f64> {
None
}
}
/// The stuck-main-thread self-sampler (`WOW_STALL_SAMPLE=0` to disable; macOS only): a watchdog
/// thread notices the main-thread heartbeat has gone stale and shells the stock profiler
/// (`/usr/bin/sample`) at our own PID, so an intermittent stall or teardown hang **diagnoses
/// itself the next time it happens** — on anyone's run, no reproduction needed. Two bugs that
/// each lost their reproduction motivated it (decision 0713): the ~1 s silent frame stalls at
/// the BWL pin, and the on-close beachball the director had to force-quit — where even the probe
/// backstop's `process::exit(0)` can wedge, because libc `exit(3)` runs the same atexit teardown
/// the hang may own. Samples land in `~/Library/Logs/benilla/` and the path prints on stderr
/// (the tracing subscriber may already be gone during teardown).
///
/// After a teardown sample, an [`EXIT_KILL_MS`](stall_sample::EXIT_KILL_MS) backstop `_exit(0)`
/// ends the wedged process — diagnosis first, then the force-quit the director would otherwise
/// perform by hand. `libc::_exit`, never `process::exit`: it skips the atexit handlers.
///
/// Verification is end-to-end via two injectors (used by the 0713 probe rounds, kept as standing
/// test affordances): `WOW_STALL_INJECT=<at_secs>:<ms>` sleeps the main thread mid-run, and
/// `WOW_TEARDOWN_INJECT=<ms>` wedges World drop via a resource whose `Drop` sleeps — the
/// director's beachball, synthetically.
#[cfg(target_os = "macos")]
mod stall_sample {
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use bevy::prelude::*;
use bevy::time::Real;
/// The monotonic epoch every heartbeat is measured against. `Instant`, not `SystemTime`:
/// a wall-clock step (NTP) must not fake a stall — an instrument that lies once is poison.
static START: OnceLock<Instant> = OnceLock::new();
/// Last main-thread heartbeat, ms since [`START`]. 0 = no full frame yet, watchdog stays
/// quiet — startup (shader compiles, first loads) never false-positives.
static HEARTBEAT_MS: AtomicU64 = AtomicU64::new(0);
/// Latched the frame `AppExit` is written: switches the watchdog to the teardown threshold
/// and arms the post-sample `_exit` backstop.
static EXITING: AtomicBool = AtomicBool::new(false);
/// In-frame staleness that means a real stall (ms) — above the ~250 ms loading-screen
/// hitches, below the ~1030 ms stall class it exists to catch.
const STALL_MS: u64 = 600;
/// In-frame sampling stays disarmed this long after launch: startup/login legitimately
/// stalls past [`STALL_MS`] (a 683 ms hitch at 1.7 s uptime, caught by the 0713 pristine-run
/// gate), and the classes this instrument hunts are steady-state. Teardown is unaffected.
const STARTUP_GRACE_MS: u64 = 15_000;
/// Post-`AppExit` staleness that means teardown is wedged, not merely slow (ms). A clean
/// teardown is sub-second (26/26 measured exit cycles); the probe hard-exit backstop sits
/// at 5 s, so a 2.5 s trigger + 2 s capture completes before it.
const EXIT_STALL_MS: u64 = 2_500;
/// Teardown backstop: still alive this long after `AppExit` (sample already taken) → `_exit`.
const EXIT_KILL_MS: u64 = 8_000;
/// Rate limit: at most this many samples per run, at least [`SAMPLE_GAP_MS`] apart.
const SAMPLE_CAP: u32 = 5;
const SAMPLE_GAP_MS: u64 = 20_000;
fn since_start_ms() -> u64 {
START.get().map_or(0, |s| s.elapsed().as_millis() as u64)
}
/// `Last`-schedule heartbeat: stamp the clock; latch [`EXITING`] the frame the app decides
/// to quit (no later frame will run to unlatch anything).
fn beat(mut exits: MessageReader<AppExit>) {
if exits.read().next().is_some() {
EXITING.store(true, Ordering::SeqCst);
}
HEARTBEAT_MS.store(since_start_ms().max(1), Ordering::SeqCst);
}
pub(super) fn plugin(app: &mut App) {
if std::env::var("WOW_STALL_SAMPLE").is_ok_and(|v| v == "0") {
return;
}
let Ok(home) = std::env::var("HOME") else {
return;
};
let dir = std::path::PathBuf::from(home).join("Library/Logs/benilla");
let _ = std::fs::create_dir_all(&dir);
START
.set(Instant::now())
.expect("stall_sample plugin built twice");
app.add_systems(Last, beat);
std::thread::Builder::new()
.name("stall-sample".into())
.spawn(move || watchdog(&dir))
.expect("spawn stall-sample watchdog");
// The injectors (doc above): a mid-run main-thread sleep, and a wedged World drop.
if let Some((at, ms)) = std::env::var("WOW_STALL_INJECT")
.ok()
.and_then(|v| v.split_once(':').map(|(a, m)| (a.to_owned(), m.to_owned())))
.and_then(|(a, m)| Some((a.parse::<f32>().ok()?, m.parse::<u64>().ok()?)))
{
app.insert_resource(StallInject {
at,
ms,
fired: false,
})
.add_systems(Update, stall_inject);
}
if let Ok(ms) = std::env::var("WOW_TEARDOWN_INJECT") {
if let Ok(ms) = ms.parse::<u64>() {
app.insert_resource(TeardownWedge(ms));
}
}
}
fn watchdog(dir: &std::path::Path) {
let pid = std::process::id().to_string();
let mut taken = 0u32;
let mut last_sample_ms = 0u64;
let mut exit_seen_ms = 0u64;
loop {
std::thread::sleep(Duration::from_millis(100));
let hb = HEARTBEAT_MS.load(Ordering::SeqCst);
if hb == 0 {
continue;
}
let now = since_start_ms();
let exiting = EXITING.load(Ordering::SeqCst);
if exiting && exit_seen_ms == 0 {
exit_seen_ms = now;
}
if exiting && now.saturating_sub(exit_seen_ms) > EXIT_KILL_MS {
eprintln!(
"stall-sample: teardown still wedged {} ms after AppExit — _exit(0)",
now.saturating_sub(exit_seen_ms)
);
use std::io::Write;
let _ = std::io::stdout().flush();
// SAFETY: terminates the process immediately; no locks, no atexit — the whole
// point, since the wedge may own both.
unsafe { libc::_exit(0) };
}
let age = now.saturating_sub(hb);
if !exiting && now < STARTUP_GRACE_MS {
continue;
}
let threshold = if exiting { EXIT_STALL_MS } else { STALL_MS };
if age > threshold
&& taken < SAMPLE_CAP
// The gap gates *between* samples only — gating the first one against
// `last_sample_ms = 0` would silently forbid any sample in the first
// [`SAMPLE_GAP_MS`] of process uptime (caught by the 0713 injector runs).
&& (taken == 0 || now.saturating_sub(last_sample_ms) > SAMPLE_GAP_MS)
{
taken += 1;
last_sample_ms = now;
let file = dir.join(format!(
"stall-{}{}.txt",
std::process::id(),
if exiting {
format!("-teardown-{now}")
} else {
format!("-{now}")
}
));
eprintln!(
"stall-sample: main thread stale {age} ms{} — sampling to {}",
if exiting { " (teardown)" } else { "" },
file.display()
);
// 1 s in-frame so the stall dominates its own capture (a ~1 s stall in a 2 s
// window is half idle); 2 s for teardown, where the wedge holds the whole time.
let _ = std::process::Command::new("/usr/bin/sample")
.arg(&pid)
.arg(if exiting { "2" } else { "1" })
.arg("-file")
.arg(&file)
.status();
}
}
}
/// `WOW_STALL_INJECT=<at_secs>:<ms>` — one deliberate main-thread sleep, mid-run.
#[derive(Resource)]
struct StallInject {
at: f32,
ms: u64,
fired: bool,
}
fn stall_inject(mut inject: ResMut<StallInject>, time: Res<Time<Real>>) {
if !inject.fired && time.elapsed_secs() >= inject.at {
inject.fired = true;
warn!("stall-inject: sleeping the main thread {} ms", inject.ms);
std::thread::sleep(Duration::from_millis(inject.ms));
}
}
/// `WOW_TEARDOWN_INJECT=<ms>` — wedge World drop: this resource's `Drop` runs on the main
/// thread during `App` teardown and sleeps there, reproducing the beachball synthetically.
#[derive(Resource)]
struct TeardownWedge(u64);
impl Drop for TeardownWedge {
fn drop(&mut self) {
eprintln!("teardown-inject: wedging World drop {} ms", self.0);
std::thread::sleep(Duration::from_millis(self.0));
}
}
}
+15 -1
View File
@@ -12,7 +12,7 @@
//! distance (clamped to the vanilla `cameraDistanceMax` range; the camera *glides* to the new
//! distance). A left-drag orbit offset *persists* — the vanilla `cameraSmoothStyle` auto-follow that
//! swung the camera back behind the character while moving is deliberately removed (director's call).
//! `F` toggles free-fly.
//! `F` toggles free-fly; `Ctrl`+`Cmd`+`G` **lands the avatar where the camera is** ([`land`]).
//!
//! Movement is a thin kinematic capsule controller over avian's `MoveAndSlide` (decision 0009).
@@ -32,6 +32,9 @@ use crate::ui_script::PointerOverUi;
mod arc;
mod camera;
mod gait;
// The land-here affordance (free-fly's other half). `pub(crate)` for its `LandHere` message, which
// the debug panel's button writes.
pub(crate) mod land;
mod move_trace;
mod movement_net;
// The kinematic mover step. `pub(crate)` because the grounded walk resolve is **not** the local
@@ -102,6 +105,17 @@ impl Plugin for PlayerPlugin {
.run_if(not(resource_exists::<crate::capture::CaptureMode>))
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
// Land-here ([`land`]): the ask, and the re-attach when the server's teleport lands.
// Before `control` so the frame that applies the teleport is the frame that takes
// third-person control back — `control` reads `detached` after this has cleared it.
.add_message::<land::LandHere>()
.add_systems(
Update,
land::land_here
.in_set(WorldStage::Input)
.before(control)
.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
// The scripted mouse-turn (`WOW_PROBE_LOOK`, decision 0621): rotates the aim before
// `control` reads it, so the frame that sees the new facing is the frame that streams
// it. Registered only when the env var asks for it — inert otherwise.
+106
View File
@@ -0,0 +1,106 @@
//! **Land here** — free-fly's missing other half: put the avatar where the camera is.
//!
//! `F` detaches the camera and the avatar freezes ([`super::camera::fly_free`]); the terrain
//! streamer follows the *camera* while detached, so you can fly anywhere and the world loads
//! around you — but there was no way to bring the body along. This is that: fly to a spot, ask to
//! land, and the avatar arrives there. Unreal's eject → possess, in our shape.
//!
//! **The teleport is the server's, not ours.** The ask goes out as the GM command `.go xyz x y z
//! <map>` (vmangos `HandleGoXYZCommand` → `HandleGoHelper`, which keeps the exact Z when one is
//! given and saves the recall position on the way), and the answer comes back as the ordinary
//! `MSG_MOVE_TELEPORT_ACK` this client already handles ([`super::wire_in`]) — so the destination
//! is one the server agrees with, zone/area/grid bookkeeping included, and nothing here invents a
//! pose on the wire. It also means the affordance needs the account's GM rights, which is the
//! default for this project's characters (decision 0679); a refused command shows up as the
//! timeout warning below rather than as silence.
//!
//! Two details worth stating, because both are choices:
//! - **The feet land at the camera point**, not the eye — the camera translation IS the
//! destination, so you arrive standing where you were floating (a ~2 yd eye rise on arrival).
//! - **Re-attach waits for the teleport**, rather than happening at the ask. Staying detached
//! through the round trip means the camera is already at the destination while the body flies
//! to it, so the tiles are resident before the landing instead of streaming in under it.
use benilla_assets::coords::bevy_to_wow;
use bevy::prelude::*;
use crate::net::{ChatKind, ClientCommand, NetCommands, TeleportMessage, WorldportMessage};
use crate::world_map::CurrentMap;
use super::camera::WorldCamera;
use super::state::Player;
/// Ask to land the avatar at the free-flying camera. Written by the debug panel's **land here**
/// button; the `Ctrl`+`Cmd`+`G` chord is read directly by [`land_here`].
#[derive(Message)]
pub(crate) struct LandHere;
/// How long to wait for the server's teleport before giving up on the ask and saying so. Generous
/// next to a local round trip: the point is to name a *refused* command (no GM rights, coordinates
/// off the map), not to race the network.
const LAND_TIMEOUT: f32 = 5.0;
/// The land-here ask, and the re-attach that closes it. Runs before [`super::control`], so the
/// frame that applies the teleport is the frame that takes third-person control again.
// One system phase's input set, like the controller's own params.
#[allow(clippy::too_many_arguments)]
pub(crate) fn land_here(
keys: Res<ButtonInput<KeyCode>>,
time: Res<Time>,
mut asks: MessageReader<LandHere>,
mut player: ResMut<Player>,
camera: Query<&Transform, With<WorldCamera>>,
map: Option<Res<CurrentMap>>,
net: Res<NetCommands>,
mut teleports: MessageReader<TeleportMessage>,
mut worldports: MessageReader<WorldportMessage>,
// The pending ask's give-up deadline. A `Local` rather than a resource because this system is
// the only writer AND the only reader — nothing else in the app has an opinion about it.
mut pending: Local<Option<f32>>,
) {
// Drain both teleport readers every frame (own cursors — `wire_in` reads the same messages
// through its own): any teleport while an ask is out is our landing.
let arrived = teleports.read().count() > 0 || worldports.read().count() > 0;
let asked = asks.read().count() > 0 || crate::debug_panel::dev_chord(&keys, KeyCode::KeyG);
if let Some(deadline) = *pending {
if arrived {
*pending = None;
player.detached = false;
info!("land: arrived — third-person control is back");
} else if time.elapsed_secs() > deadline {
*pending = None;
warn!(
"land: no teleport came back within {LAND_TIMEOUT:.0}s — the `.go xyz` was refused \
(GM rights? coordinates off the map?). Still free-flying."
);
}
}
if !asked {
return;
}
if !player.active {
info!("land: not in the world yet — nothing to land");
return;
}
if !player.detached {
info!("land: not free-flying — press F, fly somewhere, then land");
return;
}
let Ok(cam) = camera.single() else {
return;
};
let [x, y, z] = bevy_to_wow(cam.translation);
let text = match map.as_ref() {
Some(m) => format!(".go xyz {x:.2} {y:.2} {z:.2} {}", m.0),
None => format!(".go xyz {x:.2} {y:.2} {z:.2}"),
};
info!("land: {text}");
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text,
});
*pending = Some(time.elapsed_secs() + LAND_TIMEOUT);
}
+22 -2
View File
@@ -70,6 +70,19 @@ pub struct RibbonTrail {
texture: Handle<Image>,
}
impl RibbonTrail {
/// The emitter bone this trail rides — the identity `WOW_PHASE=particles:<bone>` arms on, and
/// the one `emdump`/`m2anim` print, so an instrument line and an asset line name the same trail.
pub(crate) fn bone(&self) -> u16 {
self.def.bone
}
/// The authored blend, and how many edges are committed right now (0 = nothing drawn yet).
pub(crate) fn shape(&self) -> (ParticleBlend, usize) {
(self.def.blend, self.edges.len())
}
}
/// Empty a trail mesh (draws nothing).
fn clear_ribbon_mesh(mesh: &mut Mesh) {
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, Vec::<[f32; 3]>::new());
@@ -83,8 +96,10 @@ fn clear_ribbon_mesh(mesh: &mut Mesh) {
/// skinned model — pass the joint and the def's baked pivot does the rebase — or the model/item
/// root). `current_anim` is the `AnimationData.dbc` id the owner's model is running (a static held
/// item rests in Stand; `None` = the model's first clip / no sequence), which the per-sequence
/// visibility gate keys off. `None` if the trail has no resolved texture, degenerate emission, or
/// is dark in that sequence.
/// visibility gate keys off. `owner_scale` is the owner placement's largest scale component — the
/// model-local [`ModelRibbon::owner_reach`] takes it to reach world yards, which is what the
/// draw-order rung is measured in. `None` if the trail has no resolved texture, degenerate
/// emission, or is dark in that sequence.
#[allow(clippy::too_many_arguments)]
pub fn spawn_ribbon(
commands: &mut Commands,
@@ -94,6 +109,7 @@ pub fn spawn_ribbon(
ribbon: &ModelRibbon,
owner: Entity,
use_pivot: bool,
owner_scale: f32,
current_anim: Option<u16>,
) -> Option<Entity> {
// Perf-bisect kill-switch: $WOW_NO_PARTICLES also spawns no ribbons (one switch, whole family).
@@ -148,6 +164,10 @@ pub fn spawn_ribbon(
alpha_mode,
cull_mode: None, // a trail is visible from both sides
double_sided: true,
// The reference's "a model's emitters draw after that model's batches" — the same
// rung the quad clouds take, from the same authored reach, because a trail is one of
// the model's emitters (`crate::particles::owner_last_bias`).
depth_bias: crate::particles::owner_last_bias(ribbon.owner_reach * owner_scale),
..default()
},
extension: WowParticleExt {
@@ -161,12 +161,14 @@ pub(super) fn spawn_emitters_for(
/// [`spawn_emitters_for`]); a static placement's trail rides the first spawned submesh entity (its
/// transform IS the placement). Trails self-despawn when their owner goes, so they don't join
/// `out` — the placement's despawn cascades within a frame.
#[allow(clippy::too_many_arguments)]
pub(super) fn spawn_ribbons_for(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
particle_materials: &mut Assets<WowParticleMaterial>,
light: &SharedLightBuffer,
ribbons: &[benilla_assets::ModelRibbon],
transform: Transform,
joints: Option<&[Entity]>,
fallback_owner: Option<Entity>,
) {
@@ -189,6 +191,7 @@ pub(super) fn spawn_ribbons_for(
rb,
owner,
use_pivot,
transform.scale.max_element(),
None,
);
}
@@ -159,6 +159,7 @@ pub(super) fn spawn_loaded_placements(
&mut particle_materials,
&shared_light,
&m.ribbons,
p.transform,
joints.as_deref(),
ents.first().copied(),
);
@@ -472,6 +473,7 @@ pub(super) fn spawn_loaded_placements(
&mut particle_materials,
&shared_light,
&m.ribbons,
d.transform,
joints.as_deref(),
ents.first().copied(),
);
+81
View File
@@ -18,6 +18,8 @@
//! pushes the trio + facing. (The client matches its zone-level area global directly —
//! `0x4a6650`; our MCNK `CurrentArea` is the leaf sub-area, so the parent walk lands on the
//! same zone.)
//!
//! …and one dev affordance beside them, [`dev_map_jump`]: **Alt+click the map to go there.**
use bevy::ecs::system::NonSendMut;
use bevy::prelude::*;
@@ -325,6 +327,82 @@ fn feed_world_map(
script.set_world_map_feed(player_zone, uv, player.facing(), corpse_uv);
}
/// **Alt+click the world map to go there** — the dev jump.
///
/// The inverse of the blip projection above: the click's UV inside the map art
/// ([`UiScript::world_map_uv_at`], the reference's own normalization) run back through the
/// displayed rect to a world `(x, y)`, sent as vmangos's `.go xy x y <mapid>` — the **no-Z** form,
/// so the server resolves the ground (`GetWaterOrGroundLevel`) instead of us guessing a height off
/// a 2-D sheet. A jump to another continent's sheet is a cross-map worldport, handled like any
/// other ([`crate::player::wire_in`]).
///
/// **Zone and continent sheets only.** At the *world* level (both continents on one sheet) the
/// client's own UV→world law `0x4a7100` is not the inverse of its world→UV law — a confirmed,
/// reproduced anomaly (wow-re Q2 verdict; see [`map_proj::world_click_world`]) — so a click there
/// would land somewhere real but wrong by ~1.33× the sheet offset. Rather than invent a corrected
/// inverse the reference doesn't have, the jump declines and says to zoom in first.
///
/// The click is NOT consumed: the faithful path (`WorldMapButton_OnClick` → `ProcessMapClick`)
/// also runs and drills into the clicked zone, which is what you want anyway — you arrive, and the
/// map is showing where you arrived. Adding a modifier fork to the reference's click law to
/// suppress that would be a dev affordance rewriting a faithful one.
#[allow(clippy::too_many_arguments)]
fn dev_map_jump(
buttons: Res<ButtonInput<MouseButton>>,
keys: Res<ButtonInput<KeyCode>>,
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
ui_scale: Res<crate::ui_script::UiScaleCvar>,
script: Option<NonSendMut<UiScript>>,
data: Option<Res<WorldMapUiData>>,
net: Res<crate::net::NetCommands>,
) {
let alt = keys.any_pressed([KeyCode::AltLeft, KeyCode::AltRight]);
if !alt || !buttons.just_pressed(MouseButton::Left) {
return;
}
let (Some(script), Some(data), Ok(window)) = (script, data, windows.single()) else {
return;
};
let Some(cursor) = window.cursor_position() else {
return;
};
// Window px (y-down) → the VM's y-up 768-virtual units, exactly as the pointer feed converts
// them (`ui_script::input`) — `world_map_uv_at` hit-tests in that space.
let s = crate::ui_script::seam_scale(window.height(), ui_scale.0);
let Some((u, v)) = script.world_map_uv_at(cursor.x / s, (window.height() - cursor.y) / s)
else {
return;
};
let (c, z) = script.world_map_selection();
let Some(cont) = c
.checked_sub(1)
.and_then(|i| data.continents.get(i as usize))
else {
info!(
"map-jump: no exact click→world law at the world level — zoom into a continent first"
);
return;
};
let rect = match z.checked_sub(1) {
None => cont.rect,
Some(i) => match cont.zones.get(i as usize) {
Some(zone) => zone.rect,
None => return,
},
};
// `zone_world` lerps BOTH axes by its single `t` (the binary's own shape) — so it is called
// once per axis, which is how the reference's own callers consume it.
let (_, wy) = map_proj::zone_world(rect, u);
let (wx, _) = map_proj::zone_world(rect, v);
let text = format!(".go xy {wx:.2} {wy:.2} {}", cont.map_id);
info!("map-jump: {text}");
let _ = net.0.send(crate::net::ClientCommand::Chat {
kind: crate::net::ChatKind::Say,
target: None,
text,
});
}
/// The world-map data feed (decision 0203 phase 2) — see the module doc.
pub(crate) struct WorldMapUiPlugin;
@@ -337,6 +415,9 @@ impl Plugin for WorldMapUiPlugin {
// After the script tick (UiInput), like the minimap's zone feed: the projection
// for a selection changed THIS tick lands next tick — invisible at frame rate.
feed_world_map.after(UiInput),
// Same slot, and for the same reason from the other side: the jump hit-tests
// against the frame rects THIS tick's resolve produced.
dev_map_jump.after(UiInput),
),
);
}