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:
Generated
+1
@@ -647,6 +647,7 @@ dependencies = [
|
||||
"benilla-wdt",
|
||||
"benilla-wmo",
|
||||
"clap",
|
||||
"glam 0.31.1",
|
||||
"image",
|
||||
]
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -344,6 +344,7 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
rb,
|
||||
owner,
|
||||
use_pivot,
|
||||
placement.scale.max_element(),
|
||||
None,
|
||||
)
|
||||
.is_some()
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user