diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index d38eb0ea..6349ffcd 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -150,6 +150,38 @@ pub struct ModelEmitter { pub geometry: Option>, } +/// Whether looping `anim` would look like anything other than the **static mesh** — the doodad +/// content gate (decision 0130, premise corrected by 0637). +/// +/// The original gate asked "does some track have >1 key", reasoning that a constant pose renders +/// identically to the un-rigged mesh. That premise is false: the static mesh is the **bind** pose, +/// and a constant clip only equals it when the constant *is* rest. `DuelingFlag.m2` is the +/// counter-example that cost us the bug — its Stand band brackets to a constant bone-0 translation +/// of `−9.124` (the flag planted in the ground) while its bind pose is 9 yards up in the air, so a +/// "constant ⇒ skip the rig" gate renders it floating. +/// +/// So: a track that MOVES qualifies, and so does a constant sitting away from rest — translation +/// ≠ 0 (the M2 translation track is a delta on the pivot offset), rotation ≠ identity, scale ≠ 1. +/// Genuinely rest-posed idles — the ~90% of placed doodads decision 0130 measured — still take the +/// static path, so that optimization survives intact. +fn idle_pose_differs(anim: &benilla_formats::ModelAnimation) -> bool { + const EPS: f32 = 1e-4; + anim.bones.iter().any(|b| { + b.translation.len() > 1 + || b.rotation.len() > 1 + || b.scale.len() > 1 + || b.translation + .iter() + .any(|(_, v)| v.iter().any(|c| c.abs() > EPS)) + || b.rotation + .iter() + .any(|(_, q)| (q[3].abs() - 1.0).abs() > EPS) + || b.scale + .iter() + .any(|(_, s)| s.iter().any(|c| (c - 1.0).abs() > EPS)) + }) +} + /// A model reference inside an M2 record (`.mdx`/`.mdl`, mixed case, backslashes) → its /// `mpq://…m2` load URL — the same extension remap the client's loader does (the physical /// archive file is `.m2`). @@ -421,19 +453,28 @@ impl AssetLoader for M2ModelLoader { graph.add_target_to_mask_group(target, 4); } } + // The model's baked PlayableAnimationLookup (decision 0082): parsed straight off the M2 + // header alongside the sequences above, so a model lacking a requested clip can resolve + // to its own baked substitute at play time (`ModelAnimations::resolve`) instead of the + // creature driver silently doing nothing. Read BEFORE the clip walk because the + // loader-idle seed below resolves through it. + let playable_animation_lookup = + parse_m2_playable_animation_lookup(&bytes).unwrap_or_default(); let mut clips = Vec::new(); - // The doodad-animation content gate (decision 0130, `ModelAnimations::first_seq`): the - // file-order-first sequence's clip index, kept only when that sequence really MOVES a bone - // (>1 key on some track). A constant 1-key pose still builds a clip below, but looping it - // is visually the static mesh — the spawn site must not pay a skin + player for it. + // The loader-idle seed (decision 0637, wow-re `gameobject-anim-arm.md` §1 — the + // corrected `0x71019b` read): the loader arms **animation id 0 ("Stand")** resolved + // through the model's own `playableAnimationLookup`, NOT the file-order-first + // sequence. For the overwhelming majority of models those coincide, which is why the + // old file-order read survived; they diverge exactly on models whose first sequence is + // a Spawn — `DuelingFlag.m2` is Spawn(145)/Stand(0)/Despawn(157), and looping its + // Spawn band left the flag hanging 9 yards in the air on a 3.3 s cycle. + let idle_id = playable_animation_lookup + .first() + .map_or(0, |p: &benilla_formats::PlayableAnim| p.resolved_id); let mut first_seq = None; for (i, anim) in sequences.iter().enumerate() { if let Some(clip) = build_animation_clip(anim, &skeleton) { - if i == 0 - && anim.bones.iter().any(|b| { - b.translation.len() > 1 || b.rotation.len() > 1 || b.scale.len() > 1 - }) - { + if first_seq.is_none() && anim.anim_id == idle_id && idle_pose_differs(anim) { first_seq = Some(clips.len()); } let clip = ctx.add_labeled_asset(format!("clip{i}"), clip); @@ -504,12 +545,6 @@ impl AssetLoader for M2ModelLoader { hand_close[1] = Some(graph.add_clip_with_mask(grip.clone(), 1 << 4, 1.0, root)); } } - // The model's baked PlayableAnimationLookup (decision 0082): parsed straight off the M2 - // header alongside the sequences above, so a model lacking a requested clip can resolve - // to its own baked substitute at play time (`ModelAnimations::resolve`) instead of the - // creature driver silently doing nothing. - let playable_animation_lookup = - parse_m2_playable_animation_lookup(&bytes).unwrap_or_default(); // Global-sequence channels alone are enough to carry `ModelAnimations`: a model whose // sequences produced no clips can still pulse/flicker on its free-running loops (the // doodad `GlobalSeqOnly` tier, decision 0130) — dropping them because `clips` came up diff --git a/crates/benilla-assets/src/model/anims.rs b/crates/benilla-assets/src/model/anims.rs index 19dac74d..5ceeef49 100644 --- a/crates/benilla-assets/src/model/anims.rs +++ b/crates/benilla-assets/src/model/anims.rs @@ -101,13 +101,19 @@ pub struct ModelAnimations { /// over the sequence pose (see [`super::GlobalBone`]). Empty for a model with no global-sequence /// tracks. pub global_bones: Vec, - /// Index into [`Self::clips`] of the **file-order-first sequence**, when that sequence actually - /// moves a bone (some track with >1 key). This is the doodad-animation content gate (decision - /// 0130): the client arms every M2 instance once at load with `animations[0].id` and loops it - /// forever (wow-re `doodad-anim-host.md`), so a placed doodad plays exactly this clip. `None` for - /// a model whose first sequence is a motionless pose (constant 1-key tracks build a clip too, but - /// looping it renders identically to the static mesh — the ~90% of placed doodads `doodadscan` - /// measured stay on the static path). + /// Index into [`Self::clips`] of the model's **loader-idle seed** — the sequence the client's + /// M2-instance loader arms once at load and plays forever (wow-re `gameobject-anim-arm.md` §1, + /// byte-verified `0x71019b`): **animation id 0 ("Stand")**, resolved through the model's own + /// `playableAnimationLookup`. NOT the file-order-first sequence — an earlier reading of + /// `doodad-anim-host.md` said `animations[0].id`, and wow-re corrected it in place (0637). + /// The two coincide for almost every model and diverge exactly on one whose first sequence is + /// a Spawn: `DuelingFlag.m2` is Spawn/Stand/Despawn, and arming file-order-0 looped its Spawn + /// band, leaving the duel flag hanging 9 yards in the air on a 3.3 s cycle. + /// + /// `None` when the resolved idle leaves every bone at **bind pose** — looping that really does + /// render identically to the static mesh, so the spawn site skips the skin + player (the + /// content gate, decision 0130; the ~90% of placed doodads `doodadscan` measured). A *constant + /// but non-rest* pose does NOT qualify for that skip — see `m2::idle_pose_differs`. pub first_seq: Option, } diff --git a/crates/benilla-formats/examples/dump_emitters.rs b/crates/benilla-formats/examples/dump_emitters.rs new file mode 100644 index 00000000..a82788c9 --- /dev/null +++ b/crates/benilla-formats/examples/dump_emitters.rs @@ -0,0 +1,52 @@ +//! Print a model's particle-emitter records — the authored numbers to hold a live +//! `WOW_PARTICLE_CENSUS` line against: `cargo run -p benilla-formats --example dump_emitters -- +//! 'World\...\RubyCrystalLarge01.m2'`. +//! +//! The pair is the check that matters (decision 0653): the census says how many particles are +//! *live*, this says how many the file asks for (`rate × lifespan`), and the over-life ramp says +//! how big they get. A mismatch is our sim's bug; a match moves the question to the look. +//! +//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. + +fn main() -> anyhow::Result<()> { + let virt = std::env::args() + .nth(1) + .ok_or_else(|| anyhow::anyhow!("usage: dump_emitters "))?; + 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)?; + for (i, e) in benilla_formats::parse_m2_particle_emitters(&bytes)? + .iter() + .enumerate() + { + println!( + "emitter[{i}] flags {:#x} bone {} shape {:?} blend {:?}", + e.flags, e.bone, e.shape, e.blend + ); + println!( + " texture {:?} tiles {}x{} head_tail {}", + e.texture, e.tile_rows, e.tile_cols, e.head_tail + ); + println!( + " speed {} var {} vrange {} hrange {} gravity {} life {} drag {}", + e.emission_speed, + e.speed_variation, + e.vertical_range, + e.horizontal_range, + e.gravity, + e.lifespan, + e.drag + ); + println!(" rate {:?}", e.emission_rate); + println!( + " area {}x{} zsrc {} tail {} spin {}", + e.area_length, e.area_width, e.z_source, e.tail_time, e.spin + ); + println!( + " twinkle speed {} pct {} min {} max {}", + e.twinkle_speed, e.twinkle_percent, e.twinkle_min, e.twinkle_max + ); + println!(" over_life {:?}", e.over_life); + } + Ok(()) +} diff --git a/crates/benilla-formats/examples/dump_model_anim.rs b/crates/benilla-formats/examples/dump_model_anim.rs new file mode 100644 index 00000000..883fec1f --- /dev/null +++ b/crates/benilla-formats/examples/dump_model_anim.rs @@ -0,0 +1,102 @@ +//! What can this model move, at all? Print the [`M2AnimSummary`](benilla_formats::M2AnimSummary) +//! — every animation channel family — plus the texture-transform (UV-scroll) tracks, for one M2 or +//! for every M2 whose path matches a substring: +//! +//! ```text +//! cargo run -p benilla-formats --example dump_model_anim -- 'World\...\RubyCrystalLarge01.m2' +//! cargo run -p benilla-formats --example dump_model_anim -- maraudon # every match +//! ``` +//! +//! This is the **falsifier for a flicker report** (decision 0653): if the summary says a model is +//! fully static and the screen shows it changing every frame, the change is not coming from the +//! model, and the hunt moves to what is drawn *over* it (particles, a second doodad) or to the +//! renderer. Answering that for the Maraudon crystal took one run of this and one of +//! `WOW_NO_PARTICLES=1`. +//! +//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. + +use std::io::Cursor; + +fn main() -> anyhow::Result<()> { + let arg = std::env::args() + .nth(1) + .ok_or_else(|| anyhow::anyhow!("usage: dump_model_anim "))?; + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + let mut chain = benilla_formats::open_chain(&data)?; + + // An exact path dumps in full; anything else is a substring sweep that prints only the models + // with something to say (a listing of "all static" is noise when you asked about a zone). + let exact = chain.contains(&arg); + let names: Vec = if exact { + vec![arg] + } else { + let pat = arg.to_lowercase(); + chain + .list()? + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_lowercase(); + l.ends_with(".m2") && l.contains(&pat) + }) + .collect() + }; + + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + let Ok(sum) = benilla_formats::parse_m2_animation_summary(&bytes) else { + continue; + }; + if !exact && sum.is_fully_static() { + continue; + } + println!("{name}"); + if sum.is_fully_static() { + println!(" FULLY STATIC — no channel family animates anything"); + } + println!( + " bones {} (seq0 motion {}) gseq {} alpha {:?} rgb {:?} transp {:?} \ + texanim {} particles {} ribbons {}", + sum.seq0_animated_bone_count, + sum.seq0_has_bone_motion, + sum.global_seq_channels.len(), + sum.color_alpha_tracks, + sum.color_rgb_tracks, + sum.transparency_tracks, + sum.texture_transform_count, + sum.particle_emitter_count, + sum.ribbon_emitter_count, + ); + if sum.texture_transform_count == 0 { + continue; + } + let Ok(fmt) = parse_m2(&bytes) else { continue }; + let m = fmt.model(); + println!( + " global_sequences {:?} texAnimLookup {:?}", + m.global_sequences, m.texture_transform_lookup + ); + for (i, t) in m.texture_transforms.iter().enumerate() { + let tr = &t.translation; + println!( + " tt[{i}] translation: interp {} gseq {:#x} keys {}", + tr.interp, + tr.gseq, + tr.keys.len() + ); + for (ts, v) in tr.keys.iter().take(8) { + println!(" t={ts:>7} ms {v:?}"); + } + if tr.keys.len() > 8 { + println!(" … {} more", tr.keys.len() - 8); + } + } + } + Ok(()) +} + +fn parse_m2(bytes: &[u8]) -> anyhow::Result { + benilla_m2::parse_m2(&mut Cursor::new(bytes)).map_err(|e| anyhow::anyhow!("parsing M2: {e}")) +} diff --git a/crates/benilla-formats/examples/list_chain.rs b/crates/benilla-formats/examples/list_chain.rs new file mode 100644 index 00000000..a54ffee0 --- /dev/null +++ b/crates/benilla-formats/examples/list_chain.rs @@ -0,0 +1,26 @@ +//! List every file in the 5875 patch chain whose path contains a substring (case-insensitive): +//! `cargo run -p benilla-formats --example list_chain -- maraudon`. The "what is this thing +//! actually called" tool — a report names a *place* ("the red crystal in Maraudon"), and finding +//! the asset behind it is the first step of every scene-render diagnosis. +//! +//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. + +fn main() -> anyhow::Result<()> { + let pat = std::env::args() + .nth(1) + .ok_or_else(|| anyhow::anyhow!("usage: list_chain [ext]"))? + .to_lowercase(); + // An optional second argument filters by extension — `list_chain maraudon m2`. + let ext = std::env::args() + .nth(2) + .map(|e| format!(".{}", e.to_lowercase())); + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + let chain = benilla_formats::open_chain(&data)?; + for e in chain.list()? { + let lower = e.name.to_lowercase(); + if lower.contains(&pat) && ext.as_ref().is_none_or(|x| lower.ends_with(x)) { + println!("{:>9} {}", e.size, e.name); + } + } + Ok(()) +} diff --git a/crates/benilla-formats/examples/what_is_here.rs b/crates/benilla-formats/examples/what_is_here.rs new file mode 100644 index 00000000..57f4df8c --- /dev/null +++ b/crates/benilla-formats/examples/what_is_here.rs @@ -0,0 +1,68 @@ +//! What is actually standing at a world coordinate: +//! `cargo run -p benilla-formats --example what_is_here -- [radius_yd]` +//! e.g. `what_is_here Kalimdor 295.72 -3689.61 60`. +//! +//! A bug report names a *place* — a `.go xyz` pin — and every scene-render diagnosis starts by +//! turning that pin into the **placements** behind it: which WMOs and which M2 doodads, with their +//! distance from the pin. The distinction is load-bearing for draw-order defects: two batches of one +//! WMO share the authored MOBA order the pipeline biases by, while a WMO and a nearby M2 doodad are +//! separate lanes with no ordering relationship at all. +//! +//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. + +fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let usage = || anyhow::anyhow!("usage: what_is_here [radius_yd]"); + let map = args.next().ok_or_else(usage)?; + let x: f32 = args.next().ok_or_else(usage)?.parse()?; + let y: f32 = args.next().ok_or_else(usage)?.parse()?; + let radius: f32 = args.next().map_or(Ok(60.0), |r| r.parse())?; + + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + let mut chain = benilla_formats::open_chain(&data)?; + let tiles = benilla_formats::MapTiles::load(&mut chain, &map)?; + let (tx, ty) = tiles.tile_at(x, y); + println!("{map}: pin ({x}, {y}) is tile {tx}_{ty}; scanning r={radius} yd across 3x3"); + + // The pin can sit near a tile seam and the building it names be authored in the neighbour, so + // sweep the 3x3 — a placement is deduped by uniqueId, which is stable across the tiles it spans. + let mut seen_wmo = std::collections::HashSet::new(); + let mut seen_doodad = std::collections::HashSet::new(); + let mut wmos: Vec<(f32, String, [f32; 3], u32)> = Vec::new(); + let mut doodads: Vec<(f32, String, [f32; 3], f32, u32)> = Vec::new(); + let dist = |p: [f32; 3]| ((p[0] - x).powi(2) + (p[1] - y).powi(2)).sqrt(); + + for (tx, ty) in tiles.existing_in_radius(x, y, 1) { + let tile = benilla_formats::load_tile_mesh(&mut chain, &map, tx, ty)?; + for w in &tile.wmos { + let d = dist(w.position); + if d <= radius && seen_wmo.insert(w.unique_id) { + wmos.push((d, w.model.clone(), w.position, w.unique_id)); + } + } + for m in &tile.doodads { + let d = dist(m.position); + if d <= radius && seen_doodad.insert(m.unique_id) { + doodads.push((d, m.model.clone(), m.position, m.scale, m.unique_id)); + } + } + } + wmos.sort_by(|a, b| a.0.total_cmp(&b.0)); + doodads.sort_by(|a, b| a.0.total_cmp(&b.0)); + + println!("\n=== WMOs ({}) ===", wmos.len()); + for (d, model, p, id) in &wmos { + println!( + "{d:7.1} yd #{id:<10} ({:.2}, {:.2}, {:.2}) {model}", + p[0], p[1], p[2] + ); + } + println!("\n=== doodads ({}) ===", doodads.len()); + for (d, model, p, scale, id) in &doodads { + println!( + "{d:7.1} yd #{id:<10} x{scale:.2} ({:.2}, {:.2}, {:.2}) {model}", + p[0], p[1], p[2] + ); + } + Ok(()) +} diff --git a/crates/benilla-formats/examples/wmo_coplanar.rs b/crates/benilla-formats/examples/wmo_coplanar.rs new file mode 100644 index 00000000..3d8c7945 --- /dev/null +++ b/crates/benilla-formats/examples/wmo_coplanar.rs @@ -0,0 +1,208 @@ +//! Which batches of a WMO are **coplanar with each other**, and by how much: +//! `cargo run -p benilla-formats --example wmo_coplanar -- [gap_yd]` +//! e.g. `wmo_coplanar orctower 0.05`. +//! +//! The falsifier for a z-fighting report. Two surfaces that swap which one is in front do it for one +//! of two reasons, and they take different fixes: a **true tie** (the author put both faces on the +//! same plane, so the depth test has nothing to decide with and the winner is whoever drew last) or +//! **depth precision** (a real but tiny separation that quantises away). The distinction is a +//! property of the *file*, not of the frame — so read it here rather than inferring it from pixels. +//! +//! Prints, for every pair of batches whose geometry overlaps in space, the smallest plane gap between +//! near-parallel faces that actually overlap. A `0.000` gap is a tie; anything else is a separation +//! to compare against the depth buffer's resolution at that range. +//! +//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. + +use benilla_formats::RenderSubmesh; + +/// Faces this close to parallel are treated as the same plane orientation (≈2.6°). +const PARALLEL_DOT: f32 = 0.999; + +/// One triangle reduced to its plane plus the box it occupies — everything the gap test needs. +struct Face { + normal: [f32; 3], + /// Plane offset: `normal · vertex`. + d: f32, + min: [f32; 3], + max: [f32; 3], + /// The triangle's own corners, kept so a batch can be compared against **itself**: two faces of + /// one flat quad share an edge and are trivially coplanar, which is not a defect. Real overlapping + /// sheets share no vertex. + verts: [[f32; 3]; 3], +} + +fn faces(sub: &RenderSubmesh) -> Vec { + sub.indices + .chunks_exact(3) + .filter_map(|t| { + let p: Vec<[f32; 3]> = t.iter().map(|&i| sub.positions[i as usize]).collect(); + let (u, v) = (sub3(p[1], p[0]), sub3(p[2], p[0])); + let n = [ + u[1] * v[2] - u[2] * v[1], + u[2] * v[0] - u[0] * v[2], + u[0] * v[1] - u[1] * v[0], + ]; + let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + if len < 1e-9 { + return None; // degenerate triangle: no plane to speak of + } + let normal = [n[0] / len, n[1] / len, n[2] / len]; + let mut min = p[0]; + let mut max = p[0]; + for q in &p[1..] { + for c in 0..3 { + min[c] = min[c].min(q[c]); + max[c] = max[c].max(q[c]); + } + } + Some(Face { + d: dot(normal, p[0]), + normal, + min, + max, + verts: [p[0], p[1], p[2]], + }) + }) + .collect() +} + +fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +/// Do the two boxes overlap, allowing `slack` of separation on every axis? +fn boxes_touch(a: &Face, b: &Face, slack: f32) -> bool { + (0..3).all(|c| a.min[c] - slack <= b.max[c] && b.min[c] - slack <= a.max[c]) +} + +/// Do the triangles share a corner? Two halves of a quad do; two stacked sheets do not. +fn shares_a_vertex(a: &Face, b: &Face) -> bool { + a.verts.iter().any(|p| { + b.verts + .iter() + .any(|q| (0..3).all(|c| (p[c] - q[c]).abs() < 1e-5)) + }) +} + +/// The smallest plane gap between near-parallel, spatially-overlapping faces of `a` and `b`, or +/// `None` if no such pair sits within `gap`. When `a` and `b` are the same batch, edge-sharing +/// neighbours are skipped — they are how a flat surface is built, not a defect. +fn closest_coplanar(a: &[Face], b: &[Face], gap: f32, same_batch: bool) -> Option { + let mut best: Option = None; + for (i, fa) in a.iter().enumerate() { + // Within one batch every unordered pair is visited once, and a face never fights itself. + let rest = if same_batch { &b[i + 1..] } else { b }; + for fb in rest { + if !boxes_touch(fa, fb, gap) || (same_batch && shares_a_vertex(fa, fb)) { + continue; + } + let d = dot(fa.normal, fb.normal); + if d.abs() < PARALLEL_DOT { + continue; + } + // Flip B's plane to A's orientation so the offsets are comparable. + let db = if d < 0.0 { -fb.d } else { fb.d }; + let g = (fa.d - db).abs(); + if g <= gap && best.is_none_or(|x| g < x) { + best = Some(g); + } + } + } + best +} + +fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let pat = args + .next() + .ok_or_else(|| anyhow::anyhow!("usage: wmo_coplanar [gap_yd]"))?; + // Report pairs whose faces sit within this gap. The default is generous — a real z-fight needs a + // gap far under a centimetre, but seeing the near misses tells you where the margin actually is. + let gap: f32 = args.next().map_or(Ok(0.05), |g| g.parse())?; + + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + let mut chain = benilla_formats::open_chain(&data)?; + let path = if pat.to_lowercase().ends_with(".wmo") { + pat.clone() + } else { + let lower = pat.to_lowercase(); + chain + .list()? + .into_iter() + .map(|e| e.name) + .find(|n| { + let l = n.to_lowercase(); + l.ends_with(".wmo") && l.contains(&lower) && !l.contains("_00") + }) + .ok_or_else(|| anyhow::anyhow!("no root .wmo matching {pat:?}"))? + }; + println!("{path}\n"); + + let subs = benilla_formats::load_wmo(&mut chain, &path)?; + println!( + "=== {} batches (spawn order = depth-bias order) ===", + subs.len() + ); + for (i, s) in subs.iter().enumerate() { + let n = s.positions.len(); + let (mut min, mut max) = ([f32::MAX; 3], [f32::MIN; 3]); + for p in &s.positions { + for c in 0..3 { + min[c] = min[c].min(p[c]); + max[c] = max[c].max(p[c]); + } + } + println!( + "[{i:3}] bias {:3} {:5} tri {:?}{}{} span ({:.1},{:.1},{:.1})..({:.1},{:.1},{:.1}) {}", + i + 1, + s.indices.len() / 3, + s.blend, + if s.interior { " INT" } else { "" }, + if s.emissive { " UNLIT" } else { "" }, + min[0], min[1], min[2], max[0], max[1], max[2], + s.texture.as_deref().unwrap_or("(none)"), + ); + let _ = n; + } + + let all: Vec> = subs.iter().map(faces).collect(); + // Facing is ignored throughout: two coplanar faces z-fight whether or not their normals agree. + println!("\n=== batches coplanar WITH THEMSELVES (gap <= {gap}) ==="); + println!("(no per-batch depth bias can separate these — the pipeline draws them as one call)"); + let mut self_found = 0; + for (i, f) in all.iter().enumerate() { + if let Some(g) = closest_coplanar(f, f, gap, true) { + self_found += 1; + println!( + "[{i:3}] gap {g:.5} yd {}", + subs[i].texture.as_deref().unwrap_or("(none)") + ); + } + } + if self_found == 0 { + println!("(none)"); + } + + println!("\n=== coplanar batch pairs (gap <= {gap}) ==="); + let mut found = 0; + for a in 0..all.len() { + for b in (a + 1)..all.len() { + if let Some(g) = closest_coplanar(&all[a], &all[b], gap, false) { + found += 1; + println!( + "[{a:3}] x [{b:3}] gap {g:.5} yd {} | {}", + subs[a].texture.as_deref().unwrap_or("(none)"), + subs[b].texture.as_deref().unwrap_or("(none)"), + ); + } + } + } + if found == 0 { + println!("(none — no two batches share a plane within {gap} yd)"); + } + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index f8d60e3c..281f9308 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -188,6 +188,17 @@ enum Command { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, + /// Sweep every `.m2` (optionally under a path prefix) and measure how far our SKELETAL parse + /// sits from the reference's sampler, per bone track per sequence band: empty bands (our + /// nearest-key clamp vs the file's own `interpolation_ranges` window), held band edges (our + /// hold vs the reference's ongoing lerp toward the next key), and STEP tracks (`interp == 0`, + /// which the reference copies and we interpolate). The population instrument behind decision + /// 0133's named residual — and the safety check that a band's keys never fall outside the + /// window the reference searches. + Bonescan { + /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. + prefix: Option, + }, /// Sweep every `.m2` (optionally under a path prefix) and census every particle-emitter /// FEATURE the corpus authors — shapes, head/tail, blend modes (incl. the folded Mod/Mod2x), /// every file-flag bit, spin signs, twinkle gates, atlas tiling, spline emitters, and the @@ -394,6 +405,7 @@ fn main() -> Result<()> { Command::Groundscan { prefix } => scan::groundscan(&mut chain, prefix.as_deref())?, Command::Geosetscan { prefix } => scan::geosetscan(&mut chain, prefix.as_deref())?, Command::Alphascan { prefix } => scan::alphascan(&mut chain, prefix.as_deref())?, + Command::Bonescan { prefix } => scan::bonescan(&mut chain, prefix.as_deref())?, Command::Partcensus { prefix } => scan::partcensus(&mut chain, prefix.as_deref())?, Command::Partscan { mask, prefix } => { let mask = parse_u32_maybe_hex(&mask) diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index 2511987b..5e2abbf4 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -3,7 +3,7 @@ //! a map block actually animates) — the population instruments that sweep many models/placements //! rather than dumping one. -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use anyhow::{Context, Result}; use benilla_formats::{Chain, M2AnimSummary, M2Light}; @@ -1429,3 +1429,510 @@ pub fn alphascan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } Ok(()) } + +/// One bone `M2Track` read straight off the file bytes (v256 stride `0x1c`: interp@0, gseq@2, +/// interpolation_ranges `M2Array`@0x04/0x08, timestamps@0x0c/0x10, values@0x14/0x18). +/// +/// Deliberately raw rather than via `parse_m2_animations`: this instrument's whole job is to +/// compare what the **file** says against what our parser currently emits, so it must not go +/// through the parser under test. +struct RawBoneTrack { + interp: u16, + gseq: u16, + ranges: Vec<(u32, u32)>, + ts: Vec, + vals: Vec<[f32; 4]>, + comps: usize, +} + +impl RawBoneTrack { + fn read(b: &[u8], o: usize, comps: usize) -> Option { + let u32_at = |p: usize| -> Option { + b.get(p..p + 4) + .map(|s| u32::from_le_bytes(s.try_into().unwrap())) + }; + let u16_at = |p: usize| -> Option { + b.get(p..p + 2) + .map(|s| u16::from_le_bytes(s.try_into().unwrap())) + }; + let f32_at = |p: usize| -> Option { + b.get(p..p + 4) + .map(|s| f32::from_le_bytes(s.try_into().unwrap())) + }; + let (interp, gseq) = (u16_at(o)?, u16_at(o + 2)?); + let (rn, ro) = (u32_at(o + 0x04)? as usize, u32_at(o + 0x08)? as usize); + let (tn, to) = (u32_at(o + 0x0c)? as usize, u32_at(o + 0x10)? as usize); + let (vn, vo) = (u32_at(o + 0x14)? as usize, u32_at(o + 0x18)? as usize); + let ranges = (0..rn) + .map_while(|i| Some((u32_at(ro + i * 8)?, u32_at(ro + i * 8 + 4)?))) + .collect(); + let stride = comps * 4; + let n = tn.min(vn); + let mut ts = Vec::with_capacity(n); + let mut vals = Vec::with_capacity(n); + for i in 0..n { + let Some(t) = u32_at(to + i * 4) else { break }; + let mut v = [0.0f32; 4]; + let mut ok = true; + for (c, slot) in v.iter_mut().take(comps).enumerate() { + match f32_at(vo + i * stride + c * 4) { + Some(f) => *slot = f, + None => ok = false, + } + } + if !ok { + break; + } + ts.push(t); + vals.push(v); + } + Some(Self { + interp, + gseq, + ranges, + ts, + vals, + comps, + }) + } + + /// The reference's sample at absolute time `t_ms` for sequence file slot `slot` — FN1 + /// (`0x713d50`) verbatim, then the sampler's own lerp/step leg (wow-re `eval.md` FN1/FN2/FN6). + fn reference(&self, slot: usize, t_ms: u32) -> Option<[f32; 4]> { + let last = self.ts.len().checked_sub(1)?; + // FN1 §1: the window is `ranges[slot]` when the array is present, else the whole key list. + let (lo, hi) = match self.ranges.get(slot) { + Some(&(lo, hi)) => (lo as usize, hi as usize), + None => (0, last), + }; + let (lo, hi) = (lo.min(last), hi.min(last)); + // FN1 §2: a collapsed window resolves to `keys[lo]` outright. + if lo >= hi { + return Some(self.vals[lo]); + } + let mut k0 = lo; + for (k, &ts) in self.ts.iter().enumerate().take(hi + 1).skip(lo) { + if ts <= t_ms { + k0 = k; + } else { + break; + } + } + // FN1 §5: `k1 = k0+1`, bounded by the TOTAL key count — never by the window's `hi`. + if self.interp == 0 || k0 + 1 > last { + return Some(self.vals[k0]); + } + let (t0, t1) = (self.ts[k0], self.ts[k0 + 1]); + if t1 <= t0 { + return Some(self.vals[k0]); + } + let f = (t_ms as f32 - t0 as f32) / (t1 as f32 - t0 as f32); + let (a, b) = (self.vals[k0], self.vals[k0 + 1]); + let mut out = [0.0f32; 4]; + for (i, slot) in out.iter_mut().enumerate() { + *slot = a[i] + (b[i] - a[i]) * f; + } + Some(out) + } + + /// What `models::anim::read_bone_track` emits for this band today: the in-band keys, or — when + /// the band holds none — the single **nearest** out-of-band key (benilla decision 0133). + /// Returns `(value_at_band_start, value_at_band_end, band_was_empty)`. + fn benilla_today(&self, start: u32, end: u32) -> Option<([f32; 4], [f32; 4], bool)> { + let inb: Vec = (0..self.ts.len()) + .filter(|&k| self.ts[k] >= start && self.ts[k] <= end) + .collect(); + if let (Some(&f), Some(&l)) = (inb.first(), inb.last()) { + // Bevy holds the first key before it and the last key after it. + return Some((self.vals[f], self.vals[l], false)); + } + let mut best: Option<(u32, usize)> = None; + for (k, &ts) in self.ts.iter().enumerate() { + let d = if ts < start { start - ts } else { ts - end }; + if best.is_none_or(|(bd, _)| d < bd) { + best = Some((d, k)); + } + } + let (_, k) = best?; + Some((self.vals[k], self.vals[k], true)) + } + + /// Distance between two sampled values in the channel's own units: **degrees** of rotation for + /// a quaternion (numerically stable near identity — `acos(dot)` has a ~0.04° floor in f32), + /// **model units** for a translation/scale vector. + fn delta(&self, a: [f32; 4], b: [f32; 4]) -> f32 { + if self.comps == 4 { + let norm = |q: [f32; 4]| { + let n = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt(); + if n > 0.0 { + [q[0] / n, q[1] / n, q[2] / n, q[3] / n] + } else { + q + } + }; + let (a, mut b) = (norm(a), norm(b)); + if a.iter().zip(b).map(|(x, y)| x * y).sum::() < 0.0 { + b = [-b[0], -b[1], -b[2], -b[3]]; + } + let d = a + .iter() + .zip(b) + .map(|(x, y)| (x - y) * (x - y)) + .sum::() + .sqrt(); + 2.0 * (d / 2.0).min(1.0).asin().to_degrees() + } else { + (0..3) + .map(|i| (a[i] - b[i]) * (a[i] - b[i])) + .sum::() + .sqrt() + } + } +} + +/// Sweep every `.m2` (optionally under a path prefix) and measure, per bone track and per sequence +/// band, how far **our** skeletal parse sits from the **reference's** sampler — the population +/// instrument behind benilla decision 0133's named residual ("an empty band clamps to the nearest +/// authored key … a named approximation of the mid-gap lerp"). +/// +/// Three separately-reported disagreements, each a distinct mechanism (wow-re `eval.md`): +/// +/// - **EMPTY bands** — a band with no keys of its own. We hold the nearest authored key; the +/// reference holds `keys[ranges[slot].lo]`, or lerps across the bracket when the window spans two +/// keys. The 0133 residual proper. +/// - **HELD edges** — a keyed band whose first key is late / last key is early. Bevy holds the edge +/// key; the reference keeps interpolating toward the neighbouring **out-of-band** key, because +/// FN1's `k1 = k0+1` is bounded by the total key count and not by the window. +/// - **STEP tracks** — `interpolation_type == 0`. The reference's samplers branch on it and copy +/// `keys[k0]` with no interpolation; our bone parse emits keys and lets Bevy interpolate, so a +/// snap becomes a glide. +/// +/// Plus the safety check the whole idea rests on: whether any band's own keys fall **outside** the +/// window `ranges[slot]` would search (they never do — if they did, adopting the window would +/// reintroduce the garbage pose 0133 records). +pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); + let names: Vec = 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(); + // Above the f32 noise floor of the stable angle formula (~1e-4°) by two orders, and far below + // anything an eye could catch — a slot over this is a real authored difference. + const ROT_EPS: f32 = 0.01; // degrees + const VEC_EPS: f32 = 1e-4; // model units + let (mut scanned, mut models_step, mut models_empty_differ, mut models_edge_differ) = + (0u32, 0u32, 0u32, 0u32); + let (mut slots_keyed, mut slots_empty) = (0u64, 0u64); + let (mut empty_differ, mut edge_differ, mut ranges_absent, mut window_violation) = + (0u64, 0u64, 0u64, 0u64); + let (mut step_tracks, mut total_tracks, mut step_bands) = (0u64, 0u64, 0u64); + let (mut gseq_tracks, mut gseq_multi, mut gseq_orphan) = (0u64, 0u64, 0u64); + let (mut gseq_ranges_absent, mut gseq_ranges_restrict) = (0u64, 0u64); + let mut gseq_orphan_models: BTreeSet = BTreeSet::new(); + let mut gseq_restrict_models: BTreeSet = BTreeSet::new(); + let mut emitted_extra = 0u64; + // (empty-band peak, keyed-edge peak) per channel kind — the bound, not a threshold count. + let (mut peak_rot, mut peak_vec) = ((0.0f32, 0.0f32), (0.0f32, 0.0f32)); + let mut emitted_models: BTreeSet = BTreeSet::new(); + // Worst offender per class: (delta, model, bone, channel, slot). + let mut worst_empty: Vec<(f32, String, usize, &'static str, usize)> = Vec::new(); + let mut worst_edge: Vec<(f32, String, usize, &'static str, usize)> = Vec::new(); + let mut step_models: BTreeMap = BTreeMap::new(); + let mut worst_step: Vec<(f32, String, usize, &'static str, usize, usize)> = Vec::new(); + for name in names { + let Ok(b) = chain.read_file(&name) else { + continue; + }; + if b.len() < 0x40 || &b[0..4] != b"MD20" { + continue; + } + scanned += 1; + let u32_at = |o: usize| -> usize { + b.get(o..o + 4) + .map(|s| u32::from_le_bytes(s.try_into().unwrap()) as usize) + .unwrap_or(0) + }; + // Sequences in FILE order (count@0x1c/ofs@0x20, stride 0x44) — the order `ranges` indexes. + let (sn, so) = (u32_at(0x1c), u32_at(0x20)); + let seqs: Vec<(u32, u32)> = (0..sn) + .map_while(|i| { + let e = so + i * 0x44; + (e + 0x44 <= b.len()).then(|| (u32_at(e + 4) as u32, u32_at(e + 8) as u32)) + }) + .collect(); + // globalSequences @0x14/0x18 — a duration per entry; 0 means the loop has no period. + let (gn, go) = (u32_at(0x14), u32_at(0x18)); + let gseq_period = |g: u16| -> u32 { + let i = g as usize; + if i >= gn { + 0 + } else { + u32_at(go + i * 4) as u32 + } + }; + let (bn, bo) = (u32_at(0x34), u32_at(0x38)); + let (mut m_step, mut m_empty, mut m_edge) = (0u64, 0u64, 0u64); + for bi in 0..bn { + let brec = bo + bi * 0x6c; + if brec + 0x6c > b.len() { + break; + } + for (off, comps, ch) in [(0x0c, 3, "trans"), (0x28, 4, "rot"), (0x44, 3, "scale")] { + let Some(tr) = RawBoneTrack::read(&b, brec + off, comps) else { + continue; + }; + if tr.ts.is_empty() { + continue; + } + total_tracks += 1; + if tr.interp == 0 { + step_tracks += 1; + m_step += 1; + // The step deviation only becomes visible when a band holds TWO keys: that is + // where the reference snaps and we glide. Measure the size of the snap. + for (slot, &(start, end)) in seqs.iter().enumerate() { + if end <= start { + continue; + } + let inb: Vec = (0..tr.ts.len()) + .filter(|&k| tr.ts[k] >= start && tr.ts[k] <= end) + .collect(); + if inb.len() < 2 { + continue; + } + step_bands += 1; + let jump = inb + .windows(2) + .map(|w| tr.delta(tr.vals[w[0]], tr.vals[w[1]])) + .fold(0.0f32, f32::max); + if jump > if comps == 4 { ROT_EPS } else { VEC_EPS } { + worst_step.push((jump, name.clone(), bi, ch, slot, inb.len())); + } + } + } + // A global-sequence track runs on its own clock. `read_bone_track` keeps the + // SINGLE-key case (a constant channel — the stowed-weapon rest quats) and leaves + // the multi-key case to `parse_m2_global_sequence_bones` → the `GlobalSeqDrive` + // lane, which needs a non-zero `globalSequences[gseq]` period. A multi-key channel + // on a ZERO-period global sequence falls between the two and is sampled by + // neither — census that gap, and the shape of the `ranges` window the reference + // would still apply here (FN1 selects the window BEFORE it resolves the gseq + // clock, so a restrictive window would clip the loop). + if tr.gseq != 0xffff { + gseq_tracks += 1; + if tr.ts.len() > 1 { + gseq_multi += 1; + if gseq_period(tr.gseq) == 0 { + gseq_orphan += 1; + gseq_orphan_models.insert(name.clone()); + } + match tr.ranges.len() { + 0 => gseq_ranges_absent += 1, + _ => { + if tr + .ranges + .iter() + .any(|&(lo, hi)| lo != 0 || hi as usize != tr.ts.len() - 1) + { + gseq_ranges_restrict += 1; + gseq_restrict_models.insert(name.clone()); + } + } + } + } + continue; + } + let eps = if comps == 4 { ROT_EPS } else { VEC_EPS }; + for (slot, &(start, end)) in seqs.iter().enumerate() { + if end <= start { + continue; + } + if tr.ranges.get(slot).is_none() { + ranges_absent += 1; + } + let Some((mine_a, mine_b, was_empty)) = tr.benilla_today(start, end) else { + continue; + }; + let (Some(ref_a), Some(ref_b)) = + (tr.reference(slot, start), tr.reference(slot, end)) + else { + continue; + }; + let d = tr.delta(mine_a, ref_a).max(tr.delta(mine_b, ref_b)); + let peak = if comps == 4 { + &mut peak_rot + } else { + &mut peak_vec + }; + if was_empty { + peak.0 = peak.0.max(d); + slots_empty += 1; + if d > eps { + empty_differ += 1; + m_empty += 1; + worst_empty.push((d, name.clone(), bi, ch, slot)); + } + } else { + peak.1 = peak.1.max(d); + slots_keyed += 1; + // Does this band's own key set sit inside the window the reference + // searches? If not, adopting the window would drop playable keys. + if let Some(&(lo, hi)) = tr.ranges.get(slot) { + let inb: Vec = (0..tr.ts.len()) + .filter(|&k| tr.ts[k] >= start && tr.ts[k] <= end) + .collect(); + if inb.first().is_some_and(|&f| (f as u32) < lo) + || inb.last().is_some_and(|&l| (l as u32) > hi) + { + window_violation += 1; + } + } + if d > eps { + edge_differ += 1; + m_edge += 1; + worst_edge.push((d, name.clone(), bi, ch, slot)); + } + } + } + } + } + // The other half of the check: what our parser ACTUALLY emits. A band-slot whose emitted + // key count exceeds its own in-band key count is one where the head/tail sample differed + // from the edge key and had to be carried — the only slots this parse changes. + for a in benilla_formats::parse_m2_animations(&b) { + for bk in &a.bones { + for (off, comps, emitted) in [ + (0x0c, 3, bk.translation.len()), + (0x28, 4, bk.rotation.len()), + (0x44, 3, bk.scale.len()), + ] { + let brec = bo + bk.bone as usize * 0x6c; + let Some(tr) = RawBoneTrack::read(&b, brec + off, comps) else { + continue; + }; + if tr.gseq != 0xffff { + continue; + } + let inb = tr + .ts + .iter() + .filter(|&&ts| ts >= a.start_ms && ts <= a.end_ms) + .count(); + if emitted > inb.max(1) { + emitted_extra += 1; + emitted_models.insert(name.clone()); + } + } + } + } + if m_step > 0 { + models_step += 1; + step_models.insert(name.clone(), m_step); + } + if m_empty > 0 { + models_empty_differ += 1; + } + if m_edge > 0 { + models_edge_differ += 1; + } + // Keep the worst-offender lists bounded without losing the tail. + for w in [&mut worst_empty, &mut worst_edge] { + if w.len() > 4096 { + w.sort_by(|a, b| b.0.total_cmp(&a.0)); + w.truncate(512); + } + } + if worst_step.len() > 4096 { + worst_step.sort_by(|a, b| b.0.total_cmp(&a.0)); + worst_step.truncate(512); + } + } + worst_empty.sort_by(|a, b| b.0.total_cmp(&a.0)); + worst_edge.sort_by(|a, b| b.0.total_cmp(&a.0)); + println!("scanned {scanned} models · {total_tracks} keyed bone tracks"); + println!("\nbone×channel×sequence slots: {slots_keyed} keyed, {slots_empty} EMPTY"); + println!(" ranges array absent for the slot: {ranges_absent}"); + println!(" band keys OUTSIDE their own ranges window: {window_violation} (must be 0 to adopt the window)"); + for (label, n, models, worst) in [ + ( + "EMPTY band (nearest-key vs the file's window)", + empty_differ, + models_empty_differ, + &worst_empty, + ), + ( + "HELD edge (hold vs the reference's ongoing lerp)", + edge_differ, + models_edge_differ, + &worst_edge, + ), + ] { + println!("\n{label}: {n} slots differ, across {models} models"); + for (d, m, bi, ch, slot) in worst.iter().take(15) { + let unit = if *ch == "rot" { "deg" } else { "u" }; + println!(" {d:9.3}{unit:<4} {m:<52} bone {bi:<4} {ch:<6} slot {slot}"); + } + } + worst_step.sort_by(|a, b| b.0.total_cmp(&a.0)); + println!( + "\nSTEP bone tracks (interp == 0, the reference copies keys[k0]): {step_tracks} of \ + {total_tracks}, in {models_step} models" + ); + println!( + " bands where a step track holds 2+ keys (we glide, the reference snaps): {step_bands}; \ + {} of them snap by more than the noise floor. Worst:", + worst_step.len() + ); + for (d, m, bi, ch, slot, n) in worst_step.iter().take(20) { + let unit = if *ch == "rot" { "deg" } else { "u" }; + println!(" {d:9.3}{unit:<4} {m:<52} bone {bi:<4} {ch:<6} slot {slot:<4} ({n} keys)"); + } + println!( + "\nGLOBAL-SEQUENCE bone tracks: {gseq_tracks} total, {gseq_multi} multi-key (the \ + `GlobalSeqDrive` lane's input; single-key ones are constants folded into every clip)" + ); + println!( + " multi-key on a ZERO-period global sequence — sampled by NEITHER lane: {gseq_orphan}, \ + in {} models", + gseq_orphan_models.len() + ); + for m in gseq_orphan_models.iter().take(10) { + println!(" {m}"); + } + println!( + " their `ranges` window: absent (whole key list) {gseq_ranges_absent}, RESTRICTIVE \ + (not [0, last] — would clip the loop) {gseq_ranges_restrict} in {} models", + gseq_restrict_models.len() + ); + for m in gseq_restrict_models.iter().take(10) { + println!(" {m}"); + } + println!( + "\nworst disagreement anywhere in the corpus (a BOUND, not a threshold count):\n \ + rotation: empty band {:.5} deg, keyed edge {:.5} deg\n \ + translation/scale: empty band {:.6} u, keyed edge {:.6} u", + peak_rot.0, peak_rot.1, peak_vec.0, peak_vec.1 + ); + println!( + "\nEMITTED clips: {emitted_extra} band-slots carry a head/tail sample beyond their own \ + in-band keys, in {} models", + emitted_models.len() + ); + for m in emitted_models.iter().take(10) { + println!(" {m}"); + } + println!(" models with any step track:"); + for (m, n) in step_models.iter().take(10) { + println!(" {n:>6} {m}"); + } + if step_models.len() > 10 { + println!(" … and {} more models", step_models.len() - 10); + } + Ok(()) +} diff --git a/crates/benilla-formats/src/creatures.rs b/crates/benilla-formats/src/creatures.rs index 6c944f3b..6349e4e3 100644 --- a/crates/benilla-formats/src/creatures.rs +++ b/crates/benilla-formats/src/creatures.rs @@ -16,7 +16,7 @@ //! //! Layouts verified against build 5875 (field counts from the file header, cross-checked with //! wowdev.wiki + vmangos `DBCStructure.h`): CreatureModelData = 16 fields (ID@0, ModelName@2, -//! ModelScale@4); CreatureDisplayInfo = 12 fields (ID@0, ModelID@1, ExtendedDisplayInfoID@3, Scale@4, +//! ModelScale@4, CollisionHeight@15); CreatureDisplayInfo = 12 fields (ID@0, ModelID@1, ExtendedDisplayInfoID@3, Scale@4, //! TextureVariation@6/7/8); CreatureDisplayInfoExtra = 19 fields (ID@0, Race@1, Sex@2, Skin@3, Face@4, //! HairStyle@5, HairColor@6, FacialHair@7, Equipment@8..17, BakeName@18). @@ -55,6 +55,10 @@ pub struct CreatureModel { /// `CreatureModelData.BloodID`; `≤ 0` = a bloodless model (elementals, ghosts — the client's /// negative-id skip). Consumed via [`crate::BloodCatalog::effect_id`]. pub blood: i32, + /// `CreatureModelData.collisionHeight` — the unit's collision box height in **raw model units** + /// (multiply by the unit's render scale for world yards; see + /// [`CreatureCatalog::collision_height`], which is the accessor every consumer should use). + pub collision_height: f32, } /// A character-model NPC's appearance, from **CreatureDisplayInfoExtra.dbc**. A humanoid NPC wears a @@ -98,12 +102,23 @@ struct DisplayRow { blood_level: u32, } +/// One CreatureModelData row (the parts we use). +#[derive(Debug, Clone)] +struct ModelRow { + path: String, + scale: f32, + /// `BloodID` — see [`CreatureModel::blood`]. Reads signed: `−1` marks a bloodless model. + blood: i32, + /// `collisionHeight` (field 15), raw model units — see [`CreatureCatalog::collision_height`]. + collision_height: f32, +} + /// Display/model tables loaded from the DBCs, resolving `displayId` → [`CreatureModel`]. pub struct CreatureCatalog { /// CreatureDisplayInfo: displayId → row. display: HashMap, - /// CreatureModelData: modelId → (model path, model scale, BloodID — see [`CreatureModel::blood`]). - models: HashMap, + /// CreatureModelData: modelId → row. + models: HashMap, /// CreatureDisplayInfoExtra: extendedDisplayInfoId → character-model NPC appearance. extra: HashMap, } @@ -118,24 +133,41 @@ impl CreatureCatalog { self.display.get(&display_id).map(|r| r.scale) } + /// A display's **collision height** in raw model units — `CreatureModelData.collisionHeight`, + /// the per-unit `h` every depth line in the client is a fraction of (swim at `0.75·h`, splash at + /// `0.4·h`, the foam gate at `2·h`; wow-re has each byte-pinned against `CMovement+0xb4`). World + /// yards = this × the unit's render scale (`OBJECT_FIELD_SCALE_X`, which the server has already + /// folded the DBC scales into) — the caller multiplies, because only it knows the live scale. + /// + /// The column is **exactly the model's own MD20 collision-box Z extent**, verified against the + /// shipped client for all thirteen character models (`tests::collision_height_is_the_m2_box`) — + /// which is what settles the space: it is authored pre-scale, like the geometry it bounds. + /// `None` when either DBC lookup misses; callers fall back to the client's own ctor default + /// (`2.0277777`, `0x616fd8`). + pub fn collision_height(&self, display_id: u32) -> Option { + let row = self.display.get(&display_id)?; + Some(self.models.get(&row.model_id)?.collision_height) + } + /// Resolve an NPC display id to its model, or `None` if either DBC lookup misses. pub fn model(&self, display_id: u32) -> Option { let row = self.display.get(&display_id)?; - let (model_path, model_scale, model_blood) = self.models.get(&row.model_id)?; + let model = self.models.get(&row.model_id)?; // A non-zero ExtendedDisplayInfoID that resolves to an extra row ⇒ a character-model NPC. let npc_appearance = (row.extended_id != 0) .then(|| self.extra.get(&row.extended_id).cloned()) .flatten(); Some(CreatureModel { - model_path: model_path.clone(), - scale: model_scale * row.scale, + model_path: model.path.clone(), + scale: model.scale * row.scale, textures: row.textures.clone(), npc_appearance, blood: if row.blood_level != 0 { row.blood_level as i32 } else { - *model_blood + model.blood }, + collision_height: model.collision_height, }) } @@ -241,9 +273,16 @@ pub fn load_creature_catalog(chain: &mut Chain) -> Result { let mut m = HashMap::with_capacity(rs.records().len()); for r in rs.records() { if let (Some(id), Some(name)) = (u32_at(r, 0), str_at(&rs, r, 2)) { - // BloodID (field 5) reads signed: −1 marks a bloodless model in the real data. - let blood = u32_at(r, 5).map_or(0, |v| v as i32); - m.insert(id, (name, f32_at(r, 4).unwrap_or(1.0), blood)); + m.insert( + id, + ModelRow { + path: name, + scale: f32_at(r, 4).unwrap_or(1.0), + // BloodID (field 5) reads signed: −1 marks a bloodless model in the real data. + blood: u32_at(r, 5).map_or(0, |v| v as i32), + collision_height: f32_at(r, 15).unwrap_or(0.0), + }, + ); } } m @@ -412,4 +451,78 @@ mod tests { "SW Guard worn-equipment ids in bodyslot order" ); } + + /// **The column's space, pinned.** `CreatureModelData.collisionHeight` is not a derived or + /// hand-authored number: it is a verbatim copy of the model's own MD20 **collision-box** Z + /// extent, in raw model units. Asserted for all thirteen player-race body models against the + /// shipped client — which is what licenses [`CreatureCatalog::collision_height`]'s contract + /// that world yards = column × render scale (it is pre-scale, exactly like the geometry it + /// bounds), and pins field index 15 so a schema shift can't silently return garbage. + /// + /// Tauren Female is the load-bearing row: `modelScale` 1.25 with a 2.111 column. If the column + /// were authored post-`modelScale` the box would read 2.111/1.25 = 1.689, so this row alone + /// refutes the "divide the model scale out" reading (which is what vmangos's server-side + /// `Unit::UpdateModelData` does). + #[test] + fn collision_height_is_the_m2_collision_box() { + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let cat = load_creature_catalog(&mut chain).expect("load creature catalog"); + + // (display id, label, the column's expected value). Display ids are the ChrRaces + // Male/FemaleDisplayId columns; Goblin shares one display across both sexes. + let races: &[(u32, &str, f32)] = &[ + (49, "HumanMale", 2.031), + (50, "HumanFemale", 1.913), + (51, "OrcMale", 2.361), + (52, "OrcFemale", 2.051), + (53, "DwarfMale", 1.667), + (54, "DwarfFemale", 1.528), + (55, "NightElfMale", 2.438), + (56, "NightElfFemale", 2.250), + (57, "ScourgeMale", 1.861), + (58, "ScourgeFemale", 1.844), + (59, "TaurenMale", 1.653), + (60, "TaurenFemale", 2.111), + (1563, "GnomeMale", 1.056), + (1564, "GnomeFemale", 1.000), + (1478, "TrollMale", 2.083), + (1479, "TrollFemale", 1.839), + ]; + for &(display_id, label, expect) in races { + let m = cat + .model(display_id) + .unwrap_or_else(|| panic!("{label}: display {display_id} resolves")); + let h = cat.collision_height(display_id).expect("collision height"); + assert!( + (h - expect).abs() < 5e-4, + "{label}: column is {h}, expected {expect}" + ); + assert_eq!(h, m.collision_height, "{label}: accessor vs CreatureModel"); + + let bytes = chain + .read_file(&crate::models::model_path(&m.model_path)) + .unwrap_or_else(|e| panic!("{label}: read {}: {e:#}", m.model_path)); + let fmt = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes[..])) + .unwrap_or_else(|e| panic!("{label}: parse M2: {e:#}")); + let hdr = &fmt.model().header; + let box_z = (hdr.collision_box_max[2] - hdr.collision_box_min[2]).abs(); + assert!( + (box_z - h).abs() < 5e-4, + "{label}: MD20 collision box Z extent {box_z} != column {h}" + ); + } + + // Not vacuous: the races must actually differ, or "one constant for everyone" would pass. + let gnome = cat.collision_height(1564).unwrap(); + let nelf = cat.collision_height(55).unwrap(); + assert!( + nelf > gnome * 2.0, + "a night elf is over twice a gnome ({nelf} vs {gnome}) — the whole point of the plumb" + ); + } } diff --git a/crates/benilla-formats/src/items.rs b/crates/benilla-formats/src/items.rs index 38811f2c..b10bd25e 100644 --- a/crates/benilla-formats/src/items.rs +++ b/crates/benilla-formats/src/items.rs @@ -83,6 +83,12 @@ pub struct ItemDisplayCatalog { } impl ItemDisplayCatalog { + /// Build a catalog from an explicit row map — for tests and synthetic fixtures, the twin of + /// `SpellCatalog::from_displays`. The live path is [`load_item_display_catalog`]. + pub fn from_displays(displays: HashMap) -> Self { + ItemDisplayCatalog { displays } + } + /// Look up a display id, or `None` if unknown. pub fn get(&self, display_id: u32) -> Option<&ItemDisplay> { self.displays.get(&display_id) diff --git a/crates/benilla-formats/src/models/anim.rs b/crates/benilla-formats/src/models/anim.rs index e6e06ba2..a190af7c 100644 --- a/crates/benilla-formats/src/models/anim.rs +++ b/crates/benilla-formats/src/models/anim.rs @@ -324,103 +324,176 @@ pub struct ModelAnimation { pub events: Vec, } -/// Read one bone `M2Track`'s keyframes for the sequence spanning `[seq_start_ms, seq_end_ms]` (absolute -/// ms), rebased to seconds from the start. `read_value` decodes one value at a byte offset; `stride` is -/// its size. The v256 `M2Track` (0x1c): `interp_type`@0, `global_seq`@2, then three `M2Array`s — -/// interpolation_ranges@0x04, **timestamps@0x0c**, **values@0x14** (each `{count u32, offset u32}`). -/// -/// Keys are selected by **absolute timestamp within the sequence's time band**, NOT via -/// `interpolation_ranges`: the flat timestamp/value arrays concatenate every sequence's keys -/// (timestamps absolute, VERIFIED), the sequences occupy disjoint bands, and this is exactly what the -/// real client's kernel does (rebase playback time into `[seqStart, seqEnd]`, then search the -/// timestamps). The `interpolation_ranges` window proved unreliable in real vanilla art — its end index -/// can point past the sequence into a later one (Chicken/Bear/Kobold/…: a key 14–64 s away), which froze -/// those creatures at a garbage pose: the range's far key is a clamp *endpoint*, never a playable -/// in-clip key. A **global-sequence** track (`global_seq != 0xffff`) loops on its own clock and is -/// skipped here (the global-sequence post-pass is deferred). -/// -/// A band with **no keys at all** still has an authored pose: the real sampler clamps/lerps between -/// the keys bracketing the band (that is what the per-sequence `interpolation_ranges` pairs encode — -/// HumanMale bone 27 rot has 4 keys and `ranges[Run] = (2, 3)`, both outside Run's band), so a keyed -/// bone is **never** left unsampled. Dropping the track instead froze the bone at whatever the -/// *previous* clip left (the task-#14 tilt: a Stand-variation waist twist rode through the entire -/// run, exposed the day the 0123 variation roll first armed non-head Stands). We emit one constant -/// key holding the **nearest** authored key's value — the value the real clamp lands on whenever the -/// band sits close to one bracket (vanilla bands are 0.3–4 s; bracket gaps are minutes), a named -/// approximation of the mid-gap lerp otherwise. -fn read_bone_track( +/// One bone-channel `M2Track`, read **once per model** rather than once per sequence: the v256 track +/// (0x1c) is `interp_type`@0, `global_seq`@2, then three `M2Array`s — interpolation_ranges@0x04, +/// **timestamps@0x0c**, **values@0x14** (each `{count u32, offset u32}`). Every sequence carves its +/// own band out of this one shared key list ([`ChannelTrack::band`]), so re-reading the arrays per +/// sequence was pure waste — HumanMale walked 143 sequences × 119 bones × 3 channels of headers to +/// build 357 tracks' worth of keys. +struct ChannelTrack { + /// `interp_type == 0`: hold each key until the next, no interpolation (the samplers' shared + /// `cmp word[track],0` dispatch — wow-re `eval.md` FN2/FN6). + step: bool, + /// Global-sequence id, `0xffff` for an ordinary sequence-timeline track. + gseq: u16, + /// The per-sequence **key-index window** `(lo, hi)`, indexed by the sequence's FILE slot — the + /// array the reference's key search selects before it looks at a single timestamp (VERIFIED + /// wow-re `eval.md` FN1 `0x713d50` §1). Empty ⇒ the reference's own `[track+4]==0` fallback, + /// "search the whole key list". + ranges: Vec<(u32, u32)>, + /// `(absolute ms, value)` keys, file order — every sequence's keys concatenated on one + /// timeline (timestamps absolute, VERIFIED). + keys: Vec<(u32, T)>, +} + +/// One bone's three channel tracks, in the bone record's own order: translation `+0x0c`, rotation +/// `+0x28`, scale `+0x44`. +type BoneChannels = ( + ChannelTrack<[f32; 3]>, + ChannelTrack<[f32; 4]>, + ChannelTrack<[f32; 3]>, +); + +/// Read one bone channel's `M2Track` whole (see [`ChannelTrack`]). `read_value` decodes one value at +/// a byte offset; `stride` is its size. Out-of-range reads truncate the key list — real vanilla art +/// relies on that tolerance. +fn read_channel_track( b: &[u8], track: usize, - seq_start_ms: u32, - seq_end_ms: u32, stride: usize, read_value: impl Fn(&[u8], usize) -> T, -) -> Vec<(f32, T)> { +) -> ChannelTrack { if track + 0x1c > b.len() { - return Vec::new(); + return ChannelTrack { + step: false, + gseq: 0xffff, + ranges: Vec::new(), + keys: Vec::new(), + }; } - let (times_n, times_o) = ( + let (rn, ro) = ( + le_u32(b, track + 0x04) as usize, + le_u32(b, track + 0x08) as usize, + ); + let (tn, to) = ( le_u32(b, track + 0x0c) as usize, le_u32(b, track + 0x10) as usize, ); - let vals_o = le_u32(b, track + 0x18) as usize; - if le_u16(b, track + 0x02) != 0xffff { - // A **global-sequence** track: driven by its own looping clock, independent of the playing - // sequence. The single-key case is a pure CONSTANT channel — this is how vanilla art authors - // the stowed-weapon attach-bone orientations (HumanMale bones 29/30/58–62: one rotation key - // at t=0 on global-seq 0, itself 0 ms long — the blade-down / shield-on-back quaternions; - // dumped 2026-07-03, the floating-stowed-sword bug). Emit the constant into every sequence's - // window (`keyframe_curve` holds a lone key across the clip). A *time-varying* multi-key - // global track (a fidget glow on its own clock) still needs the global clock — deferred. - if times_n == 1 && vals_o + stride <= b.len() { - return vec![(0.0, read_value(b, vals_o))]; - } - return Vec::new(); + let vo = le_u32(b, track + 0x18) as usize; + let ranges = (0..rn) + .map_while(|i| { + let e = ro + i * 8; + (e + 8 <= b.len()).then(|| (le_u32(b, e), le_u32(b, e + 4))) + }) + .collect(); + let keys = (0..tn) + .map_while(|k| { + let (t_off, v_off) = (to + k * 4, vo + k * stride); + (t_off + 4 <= b.len() && v_off + stride <= b.len()) + .then(|| (le_u32(b, t_off), read_value(b, v_off))) + }) + .collect(); + ChannelTrack { + step: le_u16(b, track) == 0, + gseq: le_u16(b, track + 0x02), + ranges, + keys, } - let mut out = Vec::new(); - // The closest out-of-band key, by distance to the band — the clamp value if the band is empty. - let mut nearest: Option<(u32, usize)> = None; - for k in 0..times_n { - let (t_off, v_off) = (times_o + k * 4, vals_o + k * stride); - if t_off + 4 > b.len() || v_off + stride > b.len() { - break; - } - let ts = le_u32(b, t_off); - if ts < seq_start_ms || ts > seq_end_ms { - // A key in another sequence's band — not playable here, but remembered as the - // clamp candidate (see the module doc: an empty band still has an authored pose). - let d = if ts < seq_start_ms { - seq_start_ms - ts - } else { - ts - seq_end_ms - }; - if nearest.is_none_or(|(best, _)| d < best) { - nearest = Some((d, k)); - } - continue; - } - out.push(((ts - seq_start_ms) as f32 / 1000.0, read_value(b, v_off))); - } - if out.is_empty() { - if let Some((_, k)) = nearest { - let v_off = vals_o + k * stride; - if v_off + stride <= b.len() { - out.push((0.0, read_value(b, v_off))); - } - } - } - out } -/// The weapon-grip finger poses: each requested `bone`'s rotation at the **HandsClosed** (`AnimationData` -/// id 15) frame, **clamped** to the nearest key at-or-before that frame. Scoped to the grip on purpose — -/// a weapon-hand finger bone is keyed in *adjacent* global-timeline bands but NOT the HandsClosed frame -/// itself (on HumanMale bone 102 is keyed at 43333/70000 ms, both curled, not the 60000 ms frame), so the -/// general per-sequence in-band read ([`read_bone_track`]) correctly drops it — yet the real client -/// evaluates the global track there and gets the curled pose (wow-re `hand-grip-mechanism.md`). Doing the -/// clamp *only* for these few finger bones is what makes the weapon hand close without re-introducing the -/// distant-key bleed the in-band read deliberately guards against. Returns `(bone, quat[x,y,z,w])` for the -/// bones that carry a plain rotation track; empty if the model has no HandsClosed sequence. +impl ChannelTrack { + /// This channel's keyframes for the sequence at file slot `slot`, spanning + /// `[start, end]` (absolute ms), rebased to seconds from the band start. + /// + /// In-band keys are selected by **absolute timestamp**, which is what the reference's search + /// resolves to: its window is `ranges[slot]`, and that window brackets the band's own keys in + /// every model of the 1.12.1 corpus (`benilla-extract bonescan`: 0 violations in 516 633 keyed + /// band-slots — the check that makes reading the window safe at all). Selecting *the window's + /// keys* as in-clip keys is the thing that never worked: `hi` routinely points at a key in a + /// later band 14–64 s away, which froze Chicken/Bear/Kobold at a garbage pose. It is a + /// **bracket**, not a key set. + /// + /// Its load-bearing use is the band **edges**, where the reference keeps sampling and a + /// truncated key list does not (wow-re `eval.md` FN1 `0x713d50`): + /// + /// - a band with **no keys of its own** still has an authored pose — the window resolves it, + /// either outright (`lo >= hi` ⇒ `keys[lo]`, the degenerate `{lo, lo, 0}` result) or as the + /// lerp across the bracketing pair. Leaving the track empty instead froze the bone at + /// whatever the *previous* clip left it (decision 0133's tilt: a Stand-variation waist twist + /// rode through the entire run); + /// - past the band's last key the reference keeps ramping toward `k1 = k0 + 1`, which is + /// bounded by the TOTAL key count and not by the window — so a key belonging to a later + /// sequence is a live lerp endpoint. + /// + /// A head/tail sample is emitted only when it differs from the edge key it would otherwise + /// hold, so the clips are unchanged wherever the reference and a plain hold agree — which, + /// measured, is everywhere but `Creature\Zombie`'s root translation (decision 0643). + /// + /// A **global-sequence** channel (`gseq != 0xffff`) runs on its own clock, not this band: a + /// lone key is a pure CONSTANT folded into every clip (how vanilla authors the stowed-weapon + /// attach-bone orientations — HumanMale bones 29/30/58–62), and a multi-key one belongs to + /// [`parse_m2_global_sequence_bones`] and the `GlobalSeqDrive` lane, so it is left out here. + fn band(&self, slot: usize, start: u32, end: u32) -> Vec<(f32, T)> { + if self.keys.is_empty() { + return Vec::new(); + } + if self.gseq != 0xffff { + return match self.keys.len() { + 1 => vec![(0.0, self.keys[0].1)], + _ => Vec::new(), + }; + } + let last = self.keys.len() - 1; + let (lo, hi) = self + .ranges + .get(slot) + .map_or((0, last), |&(lo, hi)| (lo as usize, hi as usize)); + let at = |t| super::key_anim::sample_window(&self.keys, self.step, lo, hi, t); + let rebase = |ts: u32| (ts.saturating_sub(start)) as f32 / 1000.0; + let in_band: Vec<(u32, T)> = self + .keys + .iter() + .copied() + .filter(|&(ts, _)| ts >= start && ts <= end) + .collect(); + let Some(&(first_ms, first_v)) = in_band.first() else { + // An empty band: the window's value, held across it — two keys only when the bracket + // lerp actually moves within the band. + let head = match at(start) { + Some(v) => v, + None => return Vec::new(), + }; + return match at(end) { + Some(tail) if tail != head => vec![(0.0, head), (rebase(end), tail)], + _ => vec![(0.0, head)], + }; + }; + let (last_ms, last_v) = in_band[in_band.len() - 1]; + let mut out = Vec::with_capacity(in_band.len() + 2); + if first_ms > start && at(start).is_some_and(|h| h != first_v) { + out.push((0.0, at(start).unwrap())); + } + out.extend(in_band.iter().map(|&(ts, v)| (rebase(ts), v))); + if last_ms < end && at(end).is_some_and(|t| t != last_v) { + out.push((rebase(end), at(end).unwrap())); + } + out + } +} + +/// The weapon-grip finger poses: each requested `bone`'s rotation **as the reference samples it at +/// the HandsClosed frame** (`AnimationData` id 15), through the same window kernel every other +/// channel uses ([`ChannelTrack::band`]). +/// +/// This exists as its own entry point because the grip is not a *clip* — it is a pose lifted out of +/// one frame and worn as a masked overlay over whatever animation is playing, so the weapon hand +/// stays closed while the body runs (wow-re `hand-grip-mechanism.md`). HandsClosed is a 33 ms band +/// that keys nothing of its own on the finger bones (HumanMale bone 102 is keyed at 43333/70000 ms, +/// both curled, not the 60000 ms frame) — its `interpolation_ranges` pair brackets it, which is +/// exactly the case the window rule resolves. +/// +/// Returns `(bone, quat[x,y,z,w])` for the bones that carry a plain rotation track; empty if the +/// model has no HandsClosed sequence. pub fn hand_grip_finger_poses(bytes: &[u8], bones: &[u16]) -> Vec<(u16, [f32; 4])> { let b = bytes; if b.len() < 0x40 || &b[0..4] != b"MD20" { @@ -428,65 +501,49 @@ pub fn hand_grip_finger_poses(bytes: &[u8], bones: &[u16]) -> Vec<(u16, [f32; 4] } let (seq_count, seq_ofs) = (le_u32(b, 0x1c) as usize, le_u32(b, 0x20) as usize); let (bone_count, bone_ofs) = (le_u32(b, 0x34) as usize, le_u32(b, 0x38) as usize); - // HandsClosed (anim id 15) → its frame's absolute start ms. - let mut frame = None; + // HandsClosed (anim id 15) → its FILE slot (what indexes the key windows) and frame start ms. + let mut hands_closed = None; for s in 0..seq_count { let rec = seq_ofs + s * 0x44; if rec + 0x44 > b.len() { break; } if le_u16(b, rec) == 15 { - frame = Some(le_u32(b, rec + 0x04)); + hands_closed = Some((s, le_u32(b, rec + 0x04))); break; } } - let Some(frame) = frame else { + let Some((slot, frame)) = hands_closed else { return Vec::new(); }; + let quat = |b: &[u8], o: usize| { + [ + le_f32(b, o), + le_f32(b, o + 4), + le_f32(b, o + 8), + le_f32(b, o + 12), + ] + }; let mut out = Vec::new(); for &bone in bones { let bi = bone as usize; - if bi >= bone_count { + if bi >= bone_count || bone_ofs + bi * 0x6c + 0x6c > b.len() { continue; } - let track = bone_ofs + bi * 0x6c + 0x28; // the bone's rotation M2Track (+0x28 in the 0x6c record) - if track + 0x1c > b.len() || le_u16(b, track + 0x02) != 0xffff { - continue; // out of range, or a global-sequence track (own clock — not this) + // The bone's rotation M2Track (+0x28 in the 0x6c record), stride 16 (a 4×f32 quaternion). + let tr = read_channel_track(b, bone_ofs + bi * 0x6c + 0x28, 16, quat); + if tr.gseq != 0xffff || tr.keys.is_empty() { + continue; // a global-sequence track runs on its own clock — not this } - let (nts, ots) = ( - le_u32(b, track + 0x0c) as usize, - le_u32(b, track + 0x10) as usize, - ); - let vals_o = le_u32(b, track + 0x18) as usize; - if nts == 0 { + let last = tr.keys.len() - 1; + let (lo, hi) = tr + .ranges + .get(slot) + .map_or((0, last), |&(lo, hi)| (lo as usize, hi as usize)); + let Some(v) = super::key_anim::sample_window(&tr.keys, tr.step, lo, hi, frame) else { continue; - } - // Clamp: the last key with timestamp <= the frame (keys are time-ascending); else the first. - let mut k = 0usize; - for i in 0..nts { - let t_off = ots + i * 4; - if t_off + 4 > b.len() { - break; - } - if le_u32(b, t_off) <= frame { - k = i; - } else { - break; - } - } - let v = vals_o + k * 16; // rotation stride 16 (a 4×f32 quaternion) - if v + 16 > b.len() { - continue; - } - out.push(( - bone, - [ - le_f32(b, v), - le_f32(b, v + 4), - le_f32(b, v + 8), - le_f32(b, v + 12), - ], - )); + }; + out.push((bone, v)); } out } @@ -544,6 +601,21 @@ pub fn parse_m2_animations(b: &[u8]) -> Vec { model_events.push((ident, data, times)); } + // Every bone's three channel tracks, read ONCE: the key arrays are shared by every sequence, + // which only slices its own band out of them (`ChannelTrack::band`). + let bone_tracks: Vec = (0..bone_count) + .map_while(|i| { + let brec = bone_ofs + i * 0x6c; + (brec + 0x60 <= b.len()).then(|| { + ( + read_channel_track(b, brec + 0x0c, 12, vec3), + read_channel_track(b, brec + 0x28, 16, quat), + read_channel_track(b, brec + 0x44, 12, vec3), + ) + }) + }) + .collect(); + let mut out = Vec::new(); for s in 0..seq_count { let rec = seq_ofs + s * 0x44; @@ -582,14 +654,11 @@ pub fn parse_m2_animations(b: &[u8]) -> Vec { } let looping = flags & 1 == 0; // VERIFIED polarity: bit0 clear loops, set clamps let mut bones = Vec::new(); - for i in 0..bone_count { - let brec = bone_ofs + i * 0x6c; - if brec + 0x60 > b.len() { - break; - } - let translation = read_bone_track(b, brec + 0x0c, start, end, 12, vec3); - let rotation = read_bone_track(b, brec + 0x28, start, end, 16, quat); - let scale = read_bone_track(b, brec + 0x44, start, end, 12, vec3); + for (i, tracks) in bone_tracks.iter().enumerate() { + let (tr, rot, sc) = tracks; + let translation = tr.band(seq_index, start, end); + let rotation = rot.band(seq_index, start, end); + let scale = sc.band(seq_index, start, end); if !(translation.is_empty() && rotation.is_empty() && scale.is_empty()) { bones.push(BoneKeys { bone: i as u16, @@ -788,13 +857,13 @@ mod tests { ); } - /// An empty band clamps to the nearest authored key instead of dropping the track (the task-#14 - /// tilt): HumanMale bone 27 (waist) rot has 4 keys, all outside Run's band — the real sampler - /// pins it via `interpolation_ranges` (Run's pair is `(2, 3)`, both out-of-band), ours must emit - /// the constant so a Stand-variation twist can't ride through the run. And the guarantee that - /// makes the fix total: on every sequence of the model, any bone keyed *anywhere* stays keyed. + /// An empty band still poses the bone instead of dropping the track (decision 0133's tilt): + /// HumanMale bone 27 (waist) rot has 4 keys, all outside Run's band, and `ranges[Run] = (2, 3)` + /// brackets it — so the clip must carry the bracketed value as a constant, or a Stand-variation + /// twist rides through the entire run. And the guarantee that makes it total: on every sequence + /// of the model, any bone keyed *anywhere* stays keyed. #[test] - fn empty_band_clamps_to_nearest_key_never_drops_the_bone() { + fn empty_band_poses_the_bone_and_never_drops_it() { let data = vanilla_data_dir(); if !data.is_dir() { eprintln!("skipping: vanilla client not present at {}", data.display()); @@ -858,4 +927,122 @@ mod tests { } } } + + /// The one place in the whole 1.12.1 corpus where the file's key window and decision 0133's + /// nearest-key approximation disagree (`benilla-extract bonescan`: 9 of 210 368 empty band-slots, + /// all three `Creature\Zombie` models) — and it disagrees for a reason worth pinning. + /// + /// `Zombie.m2` bone 0 (the root) translation is a **step** track (`interp == 0`) with two keys: + /// `3333 = (0,0,0)` and `30000 = (0.0047, 0, 0)`. Walk's band `[26667, 27333]` keys nothing and + /// sits nearer the *second* key in time, so "nearest authored key" hands back a value the + /// reference has not stepped to yet — a key from the future. The reference searches `ranges[slot] + /// = (0, 1)`, takes `k0` = the last key at or before the time (key 0), and — being a step track — + /// copies it. Small in yards, wrong in direction, and only the file's own window gets it right. + #[test] + fn empty_band_takes_the_window_key_not_a_later_one() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("Creature\\Zombie\\Zombie.m2") + .expect("read Zombie.m2"); + let seqs = parse_m2_animations(&bytes); + for anim in [4, 5] { + let s = seqs + .iter() + .find(|s| s.anim_id == anim) + .unwrap_or_else(|| panic!("Zombie has anim {anim}")); + let root = s + .bones + .iter() + .find(|b| b.bone == 0) + .expect("the root is carried into the clip"); + assert_eq!( + root.translation.len(), + 1, + "a step track's empty band holds one value across the band" + ); + assert_eq!( + root.translation[0].1, + [0.0, 0.0, 0.0], + "anim {anim} must hold key 0, not step early to the 0.0047 key at 30000 ms" + ); + } + } + + /// The weapon grip, which now reads through the shared window kernel rather than its own key + /// scan: HandsClosed on `HumanMale.m2` is the 33 ms band `[60000, 60033]`, which keys nothing on + /// finger bone 102 — its window brackets it with the keys at 43333/70000 ms, both the same + /// curled quaternion, so the pose the overlay wears is that curl and not the rest pose. The + /// substitution is a measured no-op: across the eight playable-race models with a HandsClosed + /// sequence, all 460 finger-capable rotation tracks give the same quaternion under the old + /// at-or-before clamp and under the window (decision 0643). + #[test] + fn hand_grip_reads_the_curled_pose_through_the_window() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("Character\\Human\\Male\\HumanMale.m2") + .expect("read HumanMale.m2"); + let poses = hand_grip_finger_poses(&bytes, &[102]); + let (bone, q) = *poses.first().expect("bone 102 carries a grip pose"); + assert_eq!(bone, 102); + let want = [0.2536, -0.016, 0.0577, 0.9654]; + for (i, (&got, &w)) in q.iter().zip(want.iter()).enumerate() { + assert!( + (got - w).abs() < 1e-3, + "grip quat component {i}: got {got}, want ~{w} (the 43333/70000 ms curl)" + ); + } + } + + /// The invariant that makes reading `interpolation_ranges` safe at all, pinned on the model + /// decision 0133 named as the counter-example (`Bear`, which froze at a garbage pose when an + /// earlier parse treated the window's far bracket as a *playable in-clip key* — it is a + /// bracket, 14–64 s away). Two halves: no emitted keyframe may fall outside its own clip, and a + /// sequence that keys a bone must carry every one of that band's own keys. Corpus-wide the + /// first half is `bonescan`'s "band keys OUTSIDE their own ranges window: 0". + #[test] + fn no_clip_carries_a_key_from_another_sequences_band() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + for model in [ + "Creature\\Bear\\Bear.m2", + "Creature\\Chicken\\Chicken.m2", + "Character\\Human\\Male\\HumanMale.m2", + ] { + let bytes = chain.read_file(model).expect("read model"); + for s in parse_m2_animations(&bytes) { + for b in &s.bones { + let times = b + .translation + .iter() + .map(|&(t, _)| ("translation", t)) + .chain(b.rotation.iter().map(|&(t, _)| ("rotation", t))) + .chain(b.scale.iter().map(|&(t, _)| ("scale", t))); + for (ch, t) in times { + assert!( + (0.0..=s.duration + 1e-3).contains(&t), + "{model} anim {} bone {} {ch}: key at {t}s is outside the \ + {}s clip — a bracket key leaked in as a playable key", + s.anim_id, + b.bone, + s.duration + ); + } + } + } + } + } } diff --git a/crates/benilla-formats/src/models/key_anim.rs b/crates/benilla-formats/src/models/key_anim.rs index b9eb4bb9..2dfc6a77 100644 --- a/crates/benilla-formats/src/models/key_anim.rs +++ b/crates/benilla-formats/src/models/key_anim.rs @@ -40,6 +40,17 @@ impl Lerp for [f32; 3] { } } +impl Lerp for [f32; 4] { + fn lerp(a: Self, b: Self, f: f32) -> Self { + [ + f32::lerp(a[0], b[0], f), + f32::lerp(a[1], b[1], f), + f32::lerp(a[2], b[2], f), + f32::lerp(a[3], b[3], f), + ] + } +} + /// The sequence a sequence-timeline track bakes against: the model's **file** sequence slot (which /// is what indexes the track's [`M2Track::ranges`] — NOT an index into a filtered animation list) /// and that sequence's absolute `(start_ms, end_ms)` band. @@ -112,7 +123,7 @@ impl KeyAnim { /// the band, and an extrapolated *fix16 factor* lands outside `[0, 1]` — where a negative value /// would cull the batch (`A <= 0`) on a data quirk rather than on authoring. We clamp the fraction /// to `[0, 1]`, i.e. hold at the bracket instead of running past it. -fn sample_window( +pub(super) fn sample_window( keys: &[(u32, V)], step: bool, lo: usize, diff --git a/crates/benilla-formats/tests/m2_loader_idle_is_stand.rs b/crates/benilla-formats/tests/m2_loader_idle_is_stand.rs new file mode 100644 index 00000000..a5170358 --- /dev/null +++ b/crates/benilla-formats/tests/m2_loader_idle_is_stand.rs @@ -0,0 +1,104 @@ +//! The loader-idle seed is **Stand**, not the file-order-first sequence (the floating duel-flag +//! bug, 2026-07-25 — decision 0637). +//! +//! `DuelingFlag.m2` is the model that separates the two readings. Its sequence table is authored +//! **Spawn(145) / Stand(0) / Despawn(157)** — file order 0 is the *Spawn*, not the idle — and its +//! geometry is modelled in the air: the bind pose sits at z ≈ +8.9…+14.7, and bone 0's translation +//! track is what plants it (`t=700 → −9.124`, held to `t=5333`). So arming file-order-0 and looping +//! it flies the flag 9 yards up every 3.3 s, while arming Stand holds it planted — which is what +//! the reference does (wow-re `gameobject-anim-arm.md` §1, byte-verified `0x71019b`: the loader +//! arms animation id 0 resolved through the model's own `playableAnimationLookup`). +//! +//! This test pins the three facts the fix rests on, straight off the shipped file. Skips (passes) +//! when the client isn't present at `/WoW/Data`. + +use std::path::PathBuf; + +use benilla_formats::{open_chain, parse_m2_animations, parse_m2_playable_animation_lookup}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +const DUEL_FLAG: &str = "World\\Generic\\PassiveDoodads\\DuelingFlag\\DuelingFlag.m2"; +/// `AnimationData.dbc`: 0 = Stand, 145 = Spawn, 157 = Despawn. +const STAND: u16 = 0; +const SPAWN: u16 = 145; +/// Bone 0's planted height (raw WoW z, the value both keys bracketing the Stand band carry). +const PLANTED_Z: f32 = -9.124369; + +#[test] +fn the_duel_flag_idle_resolves_to_stand_and_sits_planted() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let bytes = chain + .read_file(DUEL_FLAG) + .expect("DuelingFlag.m2 in the chain"); + let anims = parse_m2_animations(&bytes); + + // 1. File order really does lead with Spawn — without this the test proves nothing. + assert_eq!( + anims.first().map(|a| a.anim_id), + Some(SPAWN), + "the model's FIRST sequence is Spawn, not Stand — the whole point of this model" + ); + + // 2. The loader's seed resolves to Stand through the model's own table. + let playable = parse_m2_playable_animation_lookup(&bytes).expect("playable lookup"); + let idle_id = playable.first().map_or(0, |p| p.resolved_id); + assert_eq!( + idle_id, STAND, + "playableAnimationLookup[0] resolves to Stand" + ); + + // 3. Stand holds the flag IN THE GROUND: bone 0's translation over the Stand band is the + // constant −9.124 (both bracketing keys carry it), not the bind pose it would sit at with + // no clip. This is the assertion that actually catches the bug coming back. + let stand = anims + .iter() + .find(|a| a.anim_id == STAND) + .expect("DuelingFlag authors a Stand sequence"); + let root = stand + .bones + .iter() + .find(|b| b.bone == 0) + .expect("Stand keys bone 0"); + assert!( + !root.translation.is_empty(), + "Stand's band has no interior keys, so it must be bracketed to the authored pose — an \ + empty channel here means the flag renders at bind pose, 9 yards in the air" + ); + for (_, v) in &root.translation { + assert!( + (v[2] - PLANTED_Z).abs() < 1e-3, + "bone 0 z should be the planted {PLANTED_Z}, got {}", + v[2] + ); + } + + // 4. Stand's AUTHORED bounds describe the planted flag — ground to tip — and emphatically NOT + // the bind pose (z +8.9..+14.7). This is the mouseover picker's volume for the armed idle + // (entity M2s are never frustum-culled — the renderer takes `NoFrustumCulling`, faithful + // to the reference's ≈1e7 entity render bounds; wow-re `unit-anim-visibility-gate.md`). + // The bind-pose `Aabb` Bevy would derive sits a whole model-height ABOVE the geometry that + // draws — as a cull box it hid the planted flag from every ground-level camera, and as a + // pick box it would put the hover target in the sky. + assert!( + stand.bounds_min[2] > -1.0 && stand.bounds_min[2] < 0.5, + "Stand's authored min z should sit at the ground, got {}", + stand.bounds_min[2] + ); + assert!( + stand.bounds_max[2] > 4.0 && stand.bounds_max[2] < 8.0, + "Stand's authored max z should be the flag's tip a few yards up, got {}", + stand.bounds_max[2] + ); + assert!( + stand.bounds_min[2] < 8.9, + "the authored box must NOT be the bind pose — that box floats a model-height in the air" + ); +} diff --git a/crates/benilla-protocol/examples/cooldown_probe.rs b/crates/benilla-protocol/examples/cooldown_probe.rs index 7895db1a..1f9696e5 100644 --- a/crates/benilla-protocol/examples/cooldown_probe.rs +++ b/crates/benilla-protocol/examples/cooldown_probe.rs @@ -12,7 +12,7 @@ //! slot-keyed probe account (method.md "The local vmangos server"; NEVER `one`, the director's //! account — a probe login there kicks their live session). Two caveats: this is a COMBAT probe, //! so it runs director-supervised (method.md: no unattended combat probes), and `.learn` is -//! SEC_DEVELOPER (5) while probe accounts are gmlevel 3 — raise the slot account via the vmangos +//! SEC_DEVELOPER (5); probe accounts are gmlevel 6 (SEC_ADMINISTRATOR), so it lands. The vmangos //! console for the run and restore it after. use std::time::{Duration, Instant}; diff --git a/crates/benilla-protocol/src/bin/benilla-world/main.rs b/crates/benilla-protocol/src/bin/benilla-world/main.rs index 61bae220..5f7dcea3 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/main.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/main.rs @@ -65,7 +65,7 @@ struct Cli { /// Live-verify the melee-swing wire (decision 0073): GM-teleport (`.go xyz`) onto a Northshire /// Kobold Vermin spawn, `CMSG_ATTACKSWING` the nearest streamed creature, and require ≥1 /// `SMSG_ATTACKERSTATEUPDATE` to arrive and decode (attacker/victim/hitInfo/damage). Needs a GM - /// account (the deploy's are gmlevel 3). + /// account (the deploy's probes are gmlevel 6). #[arg(long)] attack: bool, /// Live-verify `CMSG_USE_ITEM`: use the item in this 1-based backpack slot (the wire's bag @@ -105,7 +105,7 @@ struct Cli { /// require `SMSG_LOOT_RESPONSE` to arrive and parse, `CMSG_AUTOSTORE_LOOT_ITEM` every row, /// `CMSG_LOOT_MONEY` if it carried gold, and `CMSG_LOOT_RELEASE` — requiring /// `SMSG_LOOT_RELEASE_RESPONSE` to close it. Prints every loot-related packet decoded. Needs a - /// GM account (the deploy's `three` is gmlevel 4). + /// GM account (the deploy's probes are gmlevel 6). #[arg(long)] loot: bool, /// Skip the nearest-creature search for `--loot` and loot this guid directly (decimal or @@ -119,7 +119,7 @@ struct Cli { /// (`.quest complete`), then `CMSG_QUESTGIVER_COMPLETE_QUEST` → `_REQUEST_REWARD` → `_CHOOSE_REWARD` /// requiring `SMSG_QUESTGIVER_QUEST_COMPLETE` (XP/money) + a `PLAYER_FIELD_COINAGE` delta. Uses /// quest 7 "Kobold Camp Cleanup" (McBride gives + takes it; XP 170, money 25c). Needs a GM - /// account (the deploy's `three` is gmlevel 4). + /// account (the deploy's probes are gmlevel 6). #[arg(long)] quest: bool, /// Live-verify the quest-LOG wire (decision 0109 — the questgiver wire's deferred second @@ -134,7 +134,7 @@ struct Cli { /// (`.quest complete 7`) and require the slot's count-state field to gain the `COMPLETE` state /// byte, then `CMSG_QUESTLOG_REMOVE_QUEST` that slot and require its id field to clear to `0` /// (no ack SMSG on this wire — the field clear *is* the confirmation). Needs a GM account (the - /// deploy's `three` is gmlevel 4). + /// deploy's probes are gmlevel 6). #[arg(long)] questlog: bool, /// Live-verify the force-speed-change wire: GM `.modify speed 1.5` (self-targeted), require @@ -143,7 +143,7 @@ struct Cli { /// then `.modify speed 1` and require a SECOND change (counter incremented, speed 7.0) on a /// still-live stream. A malformed ack body throws in the server's parser and drops the session /// (the `--charge` precedent), so survival through both round trips is the wire proof. Needs a - /// GM account (the deploy's `three` is gmlevel 4). + /// GM account (the deploy's probes are gmlevel 6). #[arg(long)] speed: bool, @@ -156,7 +156,7 @@ struct Cli { /// requires an `SMSG_UPDATE_AURA_DURATION` for each aura's own slot carrying the duration we /// asked for, and asserts it arrived **before** the descriptor delta that names the slot (the /// ordering the aura model depends on). Leaves the character as found (`.unaura` both). Needs a - /// GM account (the deploy's `three` is gmlevel 4). + /// GM account (the deploy's probes are gmlevel 6). #[arg(long)] aura: bool, @@ -171,7 +171,7 @@ struct Cli { /// `SMSG_UPDATE_WORLD_STATE` — what the NPC-text `$w`/`$e` tokens read): teleport to /// Elwynn, hop to Stormwind to force a zone-change init, and `.debug send worldstate` one /// synthetic pair to require back. Prints every state received. Needs a **SEC_DEVELOPER** - /// account: `.debug send worldstate` is gmlevel 5, one above the 3 the slot-keyed probe + /// account: `.debug send worldstate` is gmlevel 5, under the 6 the slot-keyed probe /// accounts carry (decision 0450), so the update leg needs a temporary grant. #[arg(long)] worldstate: bool, @@ -183,7 +183,7 @@ struct Cli { /// the same server spline machinery as any creature (not a teleport / not a knockback). Prints /// the self spline in full (facing kind, duration, waypoints, flying bit) so the ride + the /// `CMSG_MOVE_SPLINE_DONE` ack it obliges can be built from the real numbers. Needs a GM warrior - /// (the deploy's `three`/Tri, gmlevel 4). + /// (any slot-keyed probe account, gmlevel 6). #[arg(long)] charge: bool, @@ -195,7 +195,7 @@ struct Cli { /// leave the character alive. Recommend `--seconds 45`: the `.die`→repop→revive round trip /// itself completes in ~10s, but the reclaim delay is only ever observed as a packet value /// (never awaited), and the slack keeps a slow local server from truncating the ghost phase. - /// Needs a GM account (the deploy's `three` is gmlevel 4). + /// Needs a GM account (the deploy's probes are gmlevel 6). #[arg(long)] death: bool, diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs index 03b7b959..5ae2e33c 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/aura.rs @@ -125,7 +125,7 @@ impl Probe for Aura { "--aura: spell 1126 never appeared in UNIT_FIELD_AURA. Either the descriptor field \ index is wrong, or the GM `.aura` command was refused — it needs gmlevel >= 4 \ (VERIFIED vmangos `Chat/Chat.cpp:1229`: SEC_BASIC_ADMIN, which is 4 in \ - `shared/Common.h:142`), and the slot-keyed probe accounts are gmlevel 3, so this \ + `shared/Common.h:142`), and the slot-keyed probe accounts are gmlevel 6, so this \ probe cannot run as `probeN` without a temporary grant (method.md, decision 0450's \ precedent for --worldstate).", )?; diff --git a/crates/benilla-protocol/src/bin/benilla-world/probes/worldstate.rs b/crates/benilla-protocol/src/bin/benilla-world/probes/worldstate.rs index ce6ce052..7cb17b50 100644 --- a/crates/benilla-protocol/src/bin/benilla-world/probes/worldstate.rs +++ b/crates/benilla-protocol/src/bin/benilla-world/probes/worldstate.rs @@ -10,7 +10,7 @@ //! //! **The update leg needs a temporary grant.** `.debug send worldstate` sits in //! `debugSendCommandTable` at **SEC_DEVELOPER = 5** (vmangos `Chat.cpp:323`) — one above the -//! gmlevel 3 the slot-keyed probe accounts carry (decision 0450). Without it the server answers +//! gmlevel 6 the slot-keyed probe accounts carry (decisions 0450/0651). Without it the server answers //! *"This command is not available to you."* in chat and sends no packet, and the probe fails on //! the update leg while the init leg still passes. //! diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 6f0f148f..03ad89ff 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -129,8 +129,9 @@ pub use quest::{ }; pub use roster::{ CharCreateReq, CharEnumItem, Character, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK, - CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME, CHAR_CREATE_NAME_IN_USE, CHAR_CREATE_SUCCESS, - CHAR_DELETE_SUCCESS, CLASS_WARRIOR, GENDER_MALE, RACE_HUMAN, + CHARACTER_FLAG_HIDE_HELM, CHARACTER_FLAG_RENAME, CHAR_CREATE_NAME_IN_USE, + CHAR_CREATE_SERVER_LIMIT, CHAR_CREATE_SUCCESS, CHAR_DELETE_SUCCESS, CLASS_WARRIOR, GENDER_MALE, + RACE_HUMAN, }; pub use skills::unlearn_skill; pub use spellbook::SpellCooldown; diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index a374b008..27ff401e 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -514,6 +514,14 @@ pub const CMSG_LEARN_TALENT: u16 = 0x0251; // 593 /// field update. pub const CMSG_UNLEARN_SKILL: u16 = 0x0202; // 514 +/// Ask to flip our own PvP flag (decision 0646). A **two-way** opcode: vmangos reads a `bool` +/// target state iff the body is exactly one byte (`Server/Packets/Misc.cpp` +/// `TogglePvP::ReadFromWorldPacket`) and otherwise *toggles* `PLAYER_FLAGS_PVP_DESIRED` +/// (`Handlers/MiscHandler.cpp` `HandleTogglePvP`). benilla sends the **empty** body — the verb is +/// a toggle. No ack: the answer arrives as the `UNIT_FIELD_FLAGS` PvP bit in a descriptor update, +/// and turning the preference *off* changes nothing until the server's 300 s drop timer expires. +pub const CMSG_TOGGLE_PVP: u16 = 0x0253; // 595 + // The solo-loot wire family (VERIFIED vmangos `Opcodes_1_12_1.h`: 264, 349-355, 357-358). // Bodies in [`super::loot`]. pub const CMSG_AUTOSTORE_LOOT_ITEM: u16 = 0x0108; // 264 diff --git a/crates/benilla-protocol/src/messages/roster.rs b/crates/benilla-protocol/src/messages/roster.rs index cbf7c3c2..313e145a 100644 --- a/crates/benilla-protocol/src/messages/roster.rs +++ b/crates/benilla-protocol/src/messages/roster.rs @@ -9,6 +9,11 @@ use crate::wire::{read_cstring, read_u32_le, read_u64_le, read_u8, Vector3d}; /// `WorldResult::CharCreateSuccess` / `CharCreateNameInUse` (`SMSG_CHAR_CREATE`). pub const CHAR_CREATE_SUCCESS: u8 = 0x2E; pub const CHAR_CREATE_NAME_IN_USE: u8 = 0x31; +/// `WorldResult::CharCreateServerLimit` — the account already holds `CharactersPerRealm` characters +/// (vmangos `HandleCharCreateOpcode`, checked against the last char-enum's count). Counted from the +/// `CHAR_CREATE_SUCCESS = 0x2E` anchor through the `SharedDefines.h` ResponseCodes order +/// (SUCCESS, ERROR, FAILED, NAME_IN_USE, DISABLED, PVP_TEAMS_VIOLATION, SERVER_LIMIT). +pub const CHAR_CREATE_SERVER_LIMIT: u8 = 0x34; /// `WorldResult::CharDeleteSuccess` (`SMSG_CHAR_DELETE`; vmangos `SharedDefines.h` ResponseCodes, /// counted from the verified `CHAR_CREATE_SUCCESS = 0x2E` anchor). pub const CHAR_DELETE_SUCCESS: u8 = 0x39; diff --git a/crates/benilla-protocol/src/world/writer/mod.rs b/crates/benilla-protocol/src/world/writer/mod.rs index cb541dc4..e72a79ee 100644 --- a/crates/benilla-protocol/src/world/writer/mod.rs +++ b/crates/benilla-protocol/src/world/writer/mod.rs @@ -38,6 +38,7 @@ //! | [`trade`] | the player-trade dance | //! | [`duel`] | accept and cancel | //! | [`mail`] | the mailbox | +//! | [`pvp`] | the one PvP-flag toggle | use std::net::TcpStream; @@ -63,6 +64,7 @@ mod mail; mod names; mod pose; mod progression; +mod pvp; mod quest; mod selection; mod self_movement; diff --git a/crates/benilla-protocol/src/world/writer/pvp.rs b/crates/benilla-protocol/src/world/writer/pvp.rs new file mode 100644 index 00000000..aa4a7874 --- /dev/null +++ b/crates/benilla-protocol/src/world/writer/pvp.rs @@ -0,0 +1,23 @@ +//! The PvP-flag `WorldWriter` send — the one outbound verb the whole PvP-flag family has +//! (decision 0646). +//! +//! There is no body builder in [`crate::messages`] to mirror here: the packet is empty, which is +//! itself the meaningful choice (see [`opcode::CMSG_TOGGLE_PVP`] — a one-byte body would mean +//! "set this exact state" instead). + +use anyhow::Result; + +use crate::messages::opcode; + +use super::WorldWriter; + +impl WorldWriter { + /// Ask the server to flip our own PvP flag (`CMSG_TOGGLE_PVP`, empty body) — `/pvp` and the + /// unit popup's PvP row. There is no ack and no immediate local effect: flagging *on* comes + /// back as the `UNIT_FIELD_FLAGS` PvP bit within the next descriptor update, while flagging + /// *off* only clears the preference — the flag itself survives until vmangos' 300 s drop + /// timer expires (`Player::UpdatePvP`). The client predicts neither. + pub fn toggle_pvp(&mut self) -> Result<()> { + self.send(opcode::CMSG_TOGGLE_PVP, &[]) + } +} diff --git a/crates/benilla-ui/src/loader/geometry.rs b/crates/benilla-ui/src/loader/geometry.rs index 5deda8ae..69912ce7 100644 --- a/crates/benilla-ui/src/loader/geometry.rs +++ b/crates/benilla-ui/src/loader/geometry.rs @@ -42,6 +42,23 @@ impl Loader<'_> { if el.attr_bool("enableMouse") { self.call(wrapper, "EnableMouse", true, dbg); } + // `` (also accepted + // inline on the element) → SetHitRectInsets: the frame's MOUSE rect, inset from its + // resolved rect. Absent sides read 0, so a partial element insets only what it names. + if let Some(ins) = children_named(el, "HitRectInsets").next() { + let src = children_named(ins, "AbsInset").next().unwrap_or(ins); + let side = |k: &str| { + src.attr(k) + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(0.0) + }; + self.call( + wrapper, + "SetHitRectInsets", + (side("left"), side("right"), side("top"), side("bottom")), + dbg, + ); + } // Still genuine gaps — the window-behaviour surface (input arbitration, decision 0068). // Flag them rather than silently dropping or faking them. for (attr, api) in [ diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs index a088ceeb..a0f81c8b 100644 --- a/crates/benilla-ui/src/loader/tests.rs +++ b/crates/benilla-ui/src/loader/tests.rs @@ -813,6 +813,40 @@ mod loader_tests { "the text region is anchored by the insets" ); } + + /// `` reaches SetHitRectInsets, and the inset band stops capturing the mouse + /// while the frame's own geometry is untouched — the ref micro-button shape (a 29x58 frame whose + /// art fills only the lower ~40, `top="18"`). + #[test] + fn hit_rect_insets_element_shrinks_the_mouse_rect() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(800.0, 600.0); + let doc = parse( + r#" + + "#, + ); + let report = load(&s, &doc, &no_files); + assert!(report.errors.is_empty(), "errors: {:?}", report.errors); + s.resolve(); + + let (l, r, t, b) = s + .eval::<(f64, f64, f64, f64)>("return Micro:GetHitRectInsets()") + .unwrap(); + assert_eq!((l, r, t, b), (0.0, 0.0, 18.0, 0.0)); + // Geometry untouched: still a 58-tall button. + assert_eq!(s.eval::("return Micro:GetHeight()").unwrap(), 58.0); + // A button is mouse-enabled by construction, so the hit test is live. + assert!( + s.hit_test(14.0, 50.0).is_none(), + "the dead 18-unit header must not capture" + ); + assert!(s.hit_test(14.0, 20.0).is_some(), "the art band still hits"); + } } mod layer_blend_tests { diff --git a/crates/benilla-ui/src/order.rs b/crates/benilla-ui/src/order.rs index d5059d48..a056c311 100644 --- a/crates/benilla-ui/src/order.rs +++ b/crates/benilla-ui/src/order.rs @@ -215,6 +215,31 @@ impl ZKey { ZKey(bits) } + /// The key a frame's own **content** draws at: its bare [`ZKey::frame`] slot promoted into its + /// region band at `layer` — behind that layer's textures, ahead of its declared font strings. + /// + /// Content is what a widget kind draws *itself*, with no region to hang it on — today only a + /// ScrollingMessageFrame's ring lines, which the client materializes as the frame's own font + /// strings (its `` child is their font instance, and a font string with no + /// `` is `ARTWORK`). At the bare frame slot they sorted before **all** of the frame's + /// regions, so the chat window's own `$parentBackground` — a `BACKGROUND` texture, faded in + /// on hover — painted over the messages and dimmed them (director, 2026-07-26; the white + /// text measured 192, exactly `255 × (1 − 0.25)`, the box's own alpha). The frame slot stays + /// what it was — the backdrop/scissor setup that must precede everything the frame owns. + #[inline] + #[must_use] + pub const fn content(self, layer: DrawLayer) -> ZKey { + ZKey( + self.0 + | (1u64 << IS_REGION_SHIFT) + | ((layer.index() as u64) << LAYER_SHIFT) + // Sub-level 0 in the same +128 bias `region` uses, and the font-string bit: content + // is text, so it takes the same textures-draw-first slot a font string would. + | (128u64 << SUBLEVEL_SHIFT) + | (1u64 << FONTSTRING_SHIFT), + ) + } + /// The raw packed value (the sort key). #[inline] pub const fn raw(self) -> u64 { diff --git a/crates/benilla-ui/src/script/editbox.rs b/crates/benilla-ui/src/script/editbox.rs index 366abd17..7dcf1462 100644 --- a/crates/benilla-ui/src/script/editbox.rs +++ b/crates/benilla-ui/src/script/editbox.rs @@ -226,6 +226,12 @@ fn set_focus_handle(lua: &Lua, h: FrameHandle) { } let old = model.focused_editbox; model.focused_editbox = Some(h); + // A fresh focus starts a fresh history session: the browse position must not leak from + // the box's previous open (programmatic SetText deliberately preserves it — the chat + // live parse's recall rewrite — so this is where a stale walk gets dropped). + if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) { + eb.end_history_browse(); + } (old.map(|o| model.frame_id(o)), model.frame_id(h)) }; if let Some(oid) = old_id { @@ -375,8 +381,8 @@ fn delete_dir(lua: &Lua, h: FrameHandle, forward: bool) { /// One UP/DOWN history-recall step: ask the state for the line to show and route it through the /// internal `set_text` path (region sync + `OnTextSet`/`OnTextChanged` — a recall resets FrameXML -/// tab-complete state exactly like any other text change). `set_text` itself leaves the browse -/// position intact; only typed edits and Lua `SetText` end browsing. +/// tab-complete state exactly like any other text change). `set_text` (internal AND Lua) leaves +/// the browse position intact; typed edits, `AddHistoryLine`, and focus gain end browsing. fn history_step_key(lua: &Lua, h: FrameHandle, older: bool) { let recalled = with_eb(lua, h, |eb| eb.history_step(older)).flatten(); if let Some(text) = recalled { diff --git a/crates/benilla-ui/src/script/editbox/methods.rs b/crates/benilla-ui/src/script/editbox/methods.rs index dc67314f..c82788ea 100644 --- a/crates/benilla-ui/src/script/editbox/methods.rs +++ b/crates/benilla-ui/src/script/editbox/methods.rs @@ -32,9 +32,11 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { "SetText", lua.create_function(|lua, (this, s): (Table, Option)| { let h = frame_handle_of(lua, &this)?; - // Programmatic SetText ends a history browse (the engine-internal recall path calls - // `set_text` directly and keeps its browse position). - with_eb(lua, h, EditBoxState::end_history_browse); + // Programmatic SetText KEEPS a history browse in progress: the chat live parse + // rewrites the box on every recalled slash line ("/s hi" → Say + "hi"), and ending + // the browse there reset every UP to the newest entry — "history only goes back 1" + // (director's report, 2026-07-26). The browse ends on typed edits, AddHistoryLine, + // and focus gain (`set_focus_handle`) — the fresh-session reset. set_text(lua, h, &s.unwrap_or_default()); Ok(()) })?, @@ -142,6 +144,27 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { "GetBlinkSpeed", lua.create_function(|lua, this: Table| with_editbox(lua, &this, |eb| eb.blink_period))?, )?; + // SetTextColor(r, g, b [, a]) — the FontInstance half an EditBox inherits in the client + // (`ChatEdit_UpdateHeader` colors the typed text this way): tint the box's text region, + // creating it lazily like every text-touching path. + m.set( + "SetTextColor", + lua.create_function( + |lua, (this, r, g, b, a): (Table, f32, f32, f32, Option)| { + let h = frame_handle_of(lua, &this)?; + let Some(rh) = super::ensure_text_region(lua, h) else { + return Err(mlua::Error::runtime("not an EditBox")); + }; + lua.app_data_mut::() + .expect("model app_data") + .region_data + .entry(rh) + .or_default() + .color = Some([r, g, b, a.unwrap_or(1.0)]); + Ok(()) + }, + )?, + )?; m.set( "SetTextInsets", lua.create_function( diff --git a/crates/benilla-ui/src/script/editbox/tests.rs b/crates/benilla-ui/src/script/editbox/tests.rs index 1e57944c..2d3c4b41 100644 --- a/crates/benilla-ui/src/script/editbox/tests.rs +++ b/crates/benilla-ui/src/script/editbox/tests.rs @@ -526,6 +526,33 @@ fn typing_ends_the_history_browse() { assert_eq!(s.eval::("return E:GetText()").unwrap(), "newer!"); } +#[test] +fn programmatic_set_text_keeps_the_browse_and_focus_gain_resets_it() { + let mut s = script(); + s.run( + r#" + E = CreateFrame("EditBox", "E") + E:SetHistoryLines(32) + E:AddHistoryLine("/say one") + E:AddHistoryLine("/g two") + E:SetFocus() + "#, + ) + .unwrap(); + // Recall the newest, then rewrite the box the way the chat live parse does on a slash + // recall ("/g two" → Guild + "two"). The browse walk must survive: the next UP lands on + // the OLDER entry, not back on the newest (the "history only goes back 1" bug). + s.editbox_action(EditAction::HistoryPrev); + assert_eq!(s.eval::("return E:GetText()").unwrap(), "/g two"); + s.run(r#"E:SetText("two")"#).unwrap(); + s.editbox_action(EditAction::HistoryPrev); + assert_eq!(s.eval::("return E:GetText()").unwrap(), "/say one"); + // Refocusing starts a fresh session: the stale walk drops, UP recalls the newest again. + s.run(r#"E:ClearFocus() E:SetFocus()"#).unwrap(); + s.editbox_action(EditAction::HistoryPrev); + assert_eq!(s.eval::("return E:GetText()").unwrap(), "/g two"); +} + #[test] fn history_caps_at_history_lines_drop_oldest() { let mut s = script(); @@ -607,6 +634,40 @@ fn text_insets_shrink_the_text_region_rect() { assert_eq!((l, r_, t, b), (15.0, 13.0, 2.0, 3.0)); } +// The FontInstance half an EditBox inherits in the client: SetTextColor tints the box's text +// region (`ChatEdit_UpdateHeader` colors the typed text this way — its absence killed the chat +// header chunk mid-run and left the /w insets stale, the caret-in-the-header bug). +#[test] +fn set_text_color_tints_the_text_region() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run( + r#" + E = CreateFrame("EditBox", "E") + E:SetPoint("BOTTOMLEFT", 100, 50) + E:SetWidth(400) + E:SetHeight(32) + E:SetText("hello") + E:SetTextColor(1.0, 0.5, 0.25) + "#, + ) + .unwrap(); + s.resolve(); + let color = s + .extract() + .iter() + .find_map(|q| match &q.content { + crate::script::QuadContent::Text { + text: Some(t), + color, + .. + } if t == "hello" => Some(*color), + _ => None, + }) + .expect("the edit box text renders"); + assert_eq!(color, Some([1.0, 0.5, 0.25, 1.0])); +} + // ── the host text-UI seam: advances, caret/selection geometry, mouse, clipboard ───────────── /// Build a focused 200×32 box at BOTTOMLEFT(100,50), resolve it, type `text`, and answer its diff --git a/crates/benilla-ui/src/script/extract.rs b/crates/benilla-ui/src/script/extract.rs index daf42c90..c4ab81bf 100644 --- a/crates/benilla-ui/src/script/extract.rs +++ b/crates/benilla-ui/src/script/extract.rs @@ -226,11 +226,23 @@ impl UiScript { // its live fade alpha in the text color. The generic region path can't do this (the lines // aren't declared FontStrings); the ring lives in the frame's kind state. if let (ZTarget::Frame(fh), Some(fr)) = (target, rect) { - // The frame's own draw slot also carries its Backdrop (behind its regions, which sort - // after the frame slot) and, for a ScrollingMessageFrame, its ring lines — both clip - // like the frame's own slot (the frame IS the clipped owner when it's a scroll child). + // The frame's own draw slot carries its Backdrop, behind its regions (which sort + // after the frame slot). A ScrollingMessageFrame's ring lines do NOT: they take the + // slot's ARTWORK *content* key ([`crate::order::ZKey::content`]) — the layer the + // client's own message font strings live in — so the frame's BACKGROUND textures + // stay behind them. At the bare slot the chat window's hover box painted over its + // own messages. Both clip like the frame's own slot (the frame IS the clipped owner + // when it's a scroll child). Self::emit_backdrop(&model, fh, fr, zkey.raw(), alpha, clip, &mut out); - Self::emit_message_lines(&model, fh, fr, zkey.raw(), alpha, clip, &mut out); + Self::emit_message_lines( + &model, + fh, + fr, + zkey.content(order::DrawLayer::Artwork).raw(), + alpha, + clip, + &mut out, + ); } } out diff --git a/crates/benilla-ui/src/script/messageframe.rs b/crates/benilla-ui/src/script/messageframe.rs index 8b6dfb36..7e247067 100644 --- a/crates/benilla-ui/src/script/messageframe.rs +++ b/crates/benilla-ui/src/script/messageframe.rs @@ -462,6 +462,62 @@ mod tests { assert!((clip.top - 124.0).abs() < 0.01 && (clip.bottom - 90.0).abs() < 0.01); } + /// The ring lines are the frame's ARTWORK content, not its bare draw slot: a BACKGROUND + /// texture of the same frame draws BEHIND them, an OVERLAY one in front. The chat window's + /// hover box is that background texture — at the bare slot it painted over the messages and + /// dimmed them (director, 2026-07-26). + #[test] + fn ring_lines_draw_above_the_frames_background_and_below_its_overlay() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(800.0, 600.0); + s.run( + "local f = CreateFrame('ScrollingMessageFrame', 'CF')\n\ + f:SetPoint('BOTTOMLEFT', 32, 90)\n\ + f:SetWidth(430)\n\ + f:SetHeight(60)\n\ + local bg = f:CreateTexture('CFBg', 'BACKGROUND')\n\ + bg:SetTexture('Interface\\\\Bg')\n\ + bg:SetAllPoints(f)\n\ + local over = f:CreateTexture('CFOver', 'OVERLAY')\n\ + over:SetTexture('Interface\\\\Over')\n\ + over:SetAllPoints(f)\n\ + f:AddMessage('hello', 1, 1, 1)", + ) + .unwrap(); + s.resolve(); + let quads = s.extract(); + let z_of = |path: &str| { + quads + .iter() + .find_map(|q| match &q.content { + crate::script::QuadContent::Texture { path: Some(p), .. } if p == path => { + Some(q.z) + } + _ => None, + }) + .expect("texture drew") + }; + let line_z = quads + .iter() + .find_map(|q| match &q.content { + crate::script::QuadContent::Text { text: Some(t), .. } if t == "hello" => Some(q.z), + _ => None, + }) + .expect("the ring line drew"); + let slot_z = quads + .iter() + .find(|q| matches!(q.content, crate::script::QuadContent::Frame)) + .map(|q| q.z) + .expect("the frame slot drew"); + let (bg_z, over_z) = (z_of("Interface\\Bg"), z_of("Interface\\Over")); + assert!(slot_z < bg_z, "the frame's own slot still leads"); + assert!( + bg_z < line_z, + "a BACKGROUND texture draws behind the messages" + ); + assert!(line_z < over_z, "an OVERLAY texture draws in front of them"); + } + #[test] fn width_change_invalidates_row_measures() { let mut s = UiScript::new().unwrap(); diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index f35e3671..97cd89a0 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -65,9 +65,11 @@ mod merchant; mod messageframe; mod minimap; mod model; +mod net_stats; mod object; mod party; mod pointer; +mod pvp; mod quest; mod quest_log; mod region; @@ -248,6 +250,7 @@ impl UiScript { unit::install(&lua)?; party::install(&lua)?; duel::install(&lua)?; + pvp::install(&lua)?; death::install(&lua)?; aura::install(&lua)?; sound::install(&lua)?; @@ -283,6 +286,7 @@ impl UiScript { cooldown::install(&lua)?; tooltip::install(&lua)?; worldmap::install(&lua)?; + net_stats::install(&lua)?; Ok(UiScript { lua, @@ -404,6 +408,15 @@ impl UiScript { std::mem::take(&mut self.model_mut().chat_input) } + /// Queue a line as if it had been typed into the chat EditBox and submitted — the headless + /// twin of `SubmitChatInput`, for scripted probes (`WOW_PROBE_CHAT`). Going through this seam + /// rather than straight to the wire is what lets a probe drive **client-side** slash commands + /// (`/duel`, `/reaction`); a plain line or a `.gm`-style server command still reaches the wire + /// exactly as before, because that is what the chat drain does with anything it doesn't own. + pub fn push_chat_input(&mut self, line: String) { + self.model_mut().chat_input.push(line); + } + /// Whether Tab was pressed in the chat edit box since the last call (`BenillaChatTabPressed`) /// — the whisper-target cycle's trigger (decision 0288 P5). pub fn take_chat_tab(&mut self) -> bool { diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index e1680fa3..806df3f4 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -152,6 +152,10 @@ pub(crate) struct Model { /// [`super::UiScript::take_duel_requests`] drain — the outbound seam ([`duel`]). There is no /// duel *snapshot* beside it: everything the UI reads arrives as event arguments. pub(crate) duel_requests: Vec, + /// PvP-flag toggles (`TogglePVP`) queued since the app's last + /// [`super::UiScript::take_pvp_toggles`] drain — the outbound seam ([`pvp`]). A count, not a + /// payload: `CMSG_TOGGLE_PVP` carries no body. + pub(crate) pvp_toggles: u32, /// Sounds queued by the Lua `PlaySound`/`PlaySoundFile` bindings since the app's last /// [`UiScript::take_sounds`] drain — the outbound Lua→app intent seam ([`sound`]). @@ -501,6 +505,11 @@ pub(crate) struct Model { /// merchant window's coin line read it. pub(crate) money: u64, + /// The reported connection round trip in ms — `GetNetStats`'s third return, pushed by the app's + /// net feed ([`UiScript::set_latency_ms`]). The AVERAGE over the app's RTT ring, not the last + /// sample; `0` = nothing measured yet. See [`super::net_stats`]. + pub(crate) net_latency_ms: u32, + /// The player's experience within the current level (`UnitXP("player")`) and the amount required /// to level (`UnitXPMax("player")`), pushed each frame they change by the app's `PLAYER_XP` / /// `PLAYER_NEXT_LEVEL_XP` feed ([`UiScript::set_player_xp`]). A player-level pair (like `money`), @@ -625,6 +634,7 @@ impl Model { party_requests: Vec::new(), tell_requests: Vec::new(), duel_requests: Vec::new(), + pvp_toggles: 0, sound_queue: Vec::new(), actions: HashMap::new(), action_states: HashMap::new(), @@ -746,6 +756,7 @@ impl Model { cursor_pos: (0.0, 0.0), modifiers: (false, false, false), money: 0, + net_latency_ms: 0, player_xp: 0, player_next_level_xp: 0, player_combat_stats: None, diff --git a/crates/benilla-ui/src/script/net_stats.rs b/crates/benilla-ui/src/script/net_stats.rs new file mode 100644 index 00000000..eb81e418 --- /dev/null +++ b/crates/benilla-ui/src/script/net_stats.rs @@ -0,0 +1,40 @@ +//! `GetNetStats()` — the connection-telemetry read the main bar's performance ("ping") meter polls. +//! +//! One global over one pushed number. The reference binding (`0x48b8b0`, wow-re +//! `system/ui/scratch/bindings.md`) returns `bandwidthIn, bandwidthOut, latency`; its latency is +//! **not** the last round trip but the average over the connection's RTT ring — wow-re net W1 +//! records `HandlePong 0x537d60` as "RTT into ring, head/tail wrap 16" feeding +//! `0x537f20 throughput/avg-RTT math (host telemetry; no wire bytes)". The app owns that ring +//! ([`crate`]'s consumer keeps it beside its ping clock) and pushes the average here; the engine +//! stays free of ECS/net reach (decision 0068 §3), exactly as [`super::unit`]'s `GetMoney` does. +//! +//! **The two bandwidth returns are `0`.** benilla measures no throughput — its socket threads tally +//! no bytes — and nothing in the 1.12 UI reads them: `MainMenuBarPerformanceBarFrame` is the whole +//! shipped caller of `GetNetStats` and discards both. They stay in the signature (numbers, not +//! `nil`, so arithmetic on them can't error) as a named gap, not a claim. + +use mlua::Lua; + +use super::Model; + +impl super::UiScript { + /// Push the reported round trip (ms) — the app's average over its RTT ring. `None` = no sample + /// yet (before the connection's first pong, or after a disconnect), which reads as `0` through + /// `GetNetStats`: the reference reports 0 for an unmeasured connection too, and the bar's + /// thresholds put 0 in the green band, which is what the real client shows at login. + pub fn set_latency_ms(&mut self, ms: Option) { + self.model_mut().net_latency_ms = ms.unwrap_or(0); + } +} + +/// Register the net-stats globals. +pub(super) fn install(lua: &Lua) -> mlua::Result<()> { + // GetNetStats() → bandwidthIn, bandwidthOut, latency (see the module doc for the two zeros). + lua.globals().set( + "GetNetStats", + lua.create_function(|lua, ()| { + let model = lua.app_data_ref::().expect("model app_data"); + Ok((0.0_f64, 0.0_f64, i64::from(model.net_latency_ms))) + })?, + ) +} diff --git a/crates/benilla-ui/src/script/object/frame_state.rs b/crates/benilla-ui/src/script/object/frame_state.rs index c8989006..59b3ba9c 100644 --- a/crates/benilla-ui/src/script/object/frame_state.rs +++ b/crates/benilla-ui/src/script/object/frame_state.rs @@ -344,6 +344,32 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> { Ok(model.arena.is_clamped_to_screen(h)) })?, )?; + // Hit-rect insets — the MOUSE rect only (widget::Frame::hit_rect_insets): the hit test shrinks + // the resolved rect by these four before testing the cursor, and nothing else reads them, so a + // frame's geometry/draw/anchor answers are unchanged. The ref sets them wherever a button's + // frame is larger than its art (the micro buttons' 18 px empty header). + m.set( + "SetHitRectInsets", + lua.create_function( + |lua, (this, left, right, top, bottom): (Table, f32, f32, f32, f32)| { + let h = frame_handle_of(lua, &this)?; + lua.app_data_mut::() + .expect("model") + .arena + .set_hit_rect_insets(h, [left, right, top, bottom]); + Ok(()) + }, + )?, + )?; + m.set( + "GetHitRectInsets", + lua.create_function(|lua, this: Table| { + let h = frame_handle_of(lua, &this)?; + let model = lua.app_data_ref::().expect("model"); + let i = model.arena.hit_rect_insets(h); + Ok((i[0], i[1], i[2], i[3])) + })?, + )?; Ok(()) } diff --git a/crates/benilla-ui/src/script/pointer.rs b/crates/benilla-ui/src/script/pointer.rs index 852d35f7..70b38c9b 100644 --- a/crates/benilla-ui/src/script/pointer.rs +++ b/crates/benilla-ui/src/script/pointer.rs @@ -85,10 +85,9 @@ impl UiScript { let scroll_sources = scroll_clip_sources(&model); let hit = order::hit_test(&sorted, |fh| { model.arena.is_mouse_enabled(fh) - && model - .resolved - .get(&fh) - .is_some_and(|r| point_in_rect(*r, x, y)) + && model.resolved.get(&fh).is_some_and(|r| { + point_in_rect(inset_rect(*r, model.arena.hit_rect_insets(fh)), x, y) + }) && effective_clip(&model, &scroll_sources, fh) .is_none_or(|c| point_in_rect(c, x, y)) }); @@ -435,3 +434,17 @@ impl UiScript { pub(super) fn point_in_rect(r: Rect, x: f32, y: f32) -> bool { x >= r.left && x <= r.right && y >= r.bottom && y <= r.top } + +/// A frame's **mouse** rect: its resolved rect shrunk by `[left, right, top, bottom]` +/// ([`crate::widget::Frame::hit_rect_insets`]). y-up, so a `top` inset lowers `top` and a `bottom` +/// inset raises `bottom`. Insets that over-shrink collapse the rect rather than inverting it — an +/// empty rect simply captures nothing, which is what the API's own "inset past the far edge" case +/// means. +fn inset_rect(r: Rect, [left, right, top, bottom]: [f32; 4]) -> Rect { + Rect { + left: r.left + left, + right: (r.right - right).max(r.left + left), + top: r.top - top, + bottom: (r.bottom + bottom).min(r.top - top), + } +} diff --git a/crates/benilla-ui/src/script/pvp.rs b/crates/benilla-ui/src/script/pvp.rs new file mode 100644 index 00000000..18a8162d --- /dev/null +++ b/crates/benilla-ui/src/script/pvp.rs @@ -0,0 +1,46 @@ +//! The PvP-flag **Era API surface** (decision 0646) — one global, no state. +//! +//! `TogglePVP()` is the whole outbound half of the PvP family: everything the UI *reads* is unit +//! state that already arrives on the [`super::unit`] snapshot (`UnitIsPVP`, `UnitIsPVPFreeForAll`, +//! `UnitFactionGroup`). The call queues a toggle the app drains +//! ([`super::UiScript::take_pvp_toggles`]) and turns into its `CMSG_TOGGLE_PVP` send, keeping the +//! engine free of ECS/net reach (decision 0068 §3) — [`super::duel`]'s shape, one verb smaller. +//! +//! The reference registers the binding at `0x48d700` and calls it from exactly one place in the +//! whole shipped 1.12 UI: `SlashCmdList["PVP"]` (ChatFrame.lua). benilla's popup row is a +//! deliberate second caller — decision 0646 §3. + +use mlua::Lua; + +use super::Model; + +impl super::UiScript { + /// Drain the PvP-flag toggles queued since the last call — each one is a `CMSG_TOGGLE_PVP`. + /// A count rather than a payload: the packet is empty, so two toggles in a frame are two + /// sends, not one collapsed intent. + pub fn take_pvp_toggles(&mut self) -> u32 { + std::mem::take(&mut self.model_mut().pvp_toggles) + } + + /// Queue a toggle from the app side — the `/pvp` slash command. In the reference that slash + /// handler *is* Lua (a one-liner over `TogglePVP`); benilla parses slash lines in Rust, so the + /// same intent enters the same queue here rather than through the global ([`super::duel`]'s + /// `queue_duel_request` reasoning, verbatim). + pub fn queue_pvp_toggle(&mut self) { + self.model_mut().pvp_toggles += 1; + } +} + +/// Register the PvP globals. +pub(super) fn install(lua: &Lua) -> mlua::Result<()> { + // TogglePVP() — /pvp and the unit popup's PvP row. Takes no argument in 1.12: the *state* + // form of the opcode (a one-byte body) has no binding, so there is nothing to pass. + lua.globals().set( + "TogglePVP", + lua.create_function(|lua, ()| { + let mut model = lua.app_data_mut::().expect("model app_data"); + model.pvp_toggles += 1; + Ok(()) + })?, + ) +} diff --git a/crates/benilla-ui/src/script/tests/input.rs b/crates/benilla-ui/src/script/tests/input.rs index 395a69cf..9804bc5f 100644 --- a/crates/benilla-ui/src/script/tests/input.rs +++ b/crates/benilla-ui/src/script/tests/input.rs @@ -205,3 +205,59 @@ fn mouse_wheel_passes_delta_to_the_captured_frame() { assert!((s.eval::("return wheel").unwrap() + 1.0).abs() < 1e-6); assert!(s.errors().is_empty(), "{:?}", s.errors()); } + +#[test] +fn hit_rect_insets_shrink_the_mouse_rect_only() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + // A 100×100 frame at the origin with the micro-button shape: an 18-unit dead header at the + // top, and 5 off each other side. + s.run( + r#" + entered = false + local a = CreateFrame("Frame", "A") + a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(100, 100); a:EnableMouse(true) + a:SetScript("OnEnter", function() entered = true end) + a:SetHitRectInsets(5, 5, 18, 5) + local l, r, t, b = a:GetHitRectInsets() + assert(l == 5 and r == 5 and t == 18 and b == 5) + "#, + ) + .unwrap(); + s.resolve(); + + // Geometry is untouched — insets move the MOUSE rect, never the frame. + assert_eq!(s.eval::("return A:GetHeight()").unwrap(), 100.0); + assert_eq!(s.eval::("return A:GetTop()").unwrap(), 100.0); + + // Inside the frame but inside the dead header ⇒ no capture (the case the ref's top=18 buys: + // the empty band above a micro button's art must not eat the click). + assert!( + s.hit_test(50.0, 90.0).is_none(), + "a point in the inset header is outside the hit rect" + ); + assert!(s.mouse_move(50.0, 90.0).is_none()); + assert!(!s.eval::("return entered").unwrap()); + // …and each of the other three insets bites too. + assert!(s.hit_test(2.0, 50.0).is_none(), "left inset"); + assert!(s.hit_test(98.0, 50.0).is_none(), "right inset"); + assert!(s.hit_test(50.0, 2.0).is_none(), "bottom inset"); + + // Just below the header, still inside the shrunken rect ⇒ captures. + assert!(s.hit_test(50.0, 80.0).is_some()); + assert!(s.mouse_move(50.0, 80.0).is_some()); + assert!(s.eval::("return entered").unwrap()); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} + +#[test] +fn hit_rect_insets_default_to_zero() { + let mut s = script(); + s.set_screen_size(800.0, 600.0); + s.run(r#"local a = CreateFrame("Frame", "Plain")"#).unwrap(); + let (l, r, t, b) = s + .eval::<(f64, f64, f64, f64)>("return Plain:GetHitRectInsets()") + .unwrap(); + assert_eq!((l, r, t, b), (0.0, 0.0, 0.0, 0.0), "no inset by default"); + assert!(s.errors().is_empty(), "{:?}", s.errors()); +} diff --git a/crates/benilla-ui/src/script/unit/bindings.rs b/crates/benilla-ui/src/script/unit/bindings.rs index 3f78ea51..39edff82 100644 --- a/crates/benilla-ui/src/script/unit/bindings.rs +++ b/crates/benilla-ui/src/script/unit/bindings.rs @@ -341,6 +341,22 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { }) })?, )?; + // UnitFactionGroup(unit) → (englishGroup, localizedName), the pair the PvP icon law reads: + // the first names the `UI-PVP-` texture, the second titles the player frame's hit-area + // tooltip. nil, nil for a unit with no side (Monster/neutral templates) or no snapshot — the + // ref's icon branches gate on exactly that (decision 0646 §1/§3). + g.set( + "UnitFactionGroup", + lua.create_function(|lua, token: Option| { + match with_unit(lua, &token, None, |u| u.faction_group.clone()) { + Some(group) => { + let s = Value::String(lua.create_string(&group)?); + Ok((s.clone(), s)) + } + None => Ok((Value::Nil, Value::Nil)), + } + })?, + )?; // UnitRace(unit) → (localized, raceFile) or nil, nil; UnitClass(unit) → (localized, // classFileName) — the paper doll's "Level %d %s %s" line + the CLASSFILENAME-keyed diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index b07487a9..998d85ba 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -125,6 +125,16 @@ pub struct UnitState { /// bit) — distinct from [`Self::pvp`]'s ordinary faction PvP flag; a unit can carry either, both, /// or neither independently (an FFA zone flags this without the ordinary flag ever setting). pub is_pvp_ffa: bool, + /// The unit's PvP faction group — `"Alliance"` or `"Horde"`, else `None` (`UnitFactionGroup`; + /// decision 0646 §1). App-resolved from `UNIT_FIELD_FACTIONTEMPLATE` → `FactionTemplate.dbc`'s + /// group mask, with the Player bit skipped: every playable race's template carries + /// Player| (mask 3 or 5), and so do the PvP-flagged city guards, so the side bit is the + /// only one that can name a shipped `UI-PVP-` texture. `None` for Monster/neutral + /// templates is a state the icon callers explicitly handle (`if ( factionGroup and … )`). + /// + /// The Era binding returns it twice — English token first, localized name second; enUS ships + /// the same word for both (FactionGroup.dbc's `InternalName` and `Name0`). + pub faction_group: Option, /// The unit's GUID (`OBJECT_FIELD_GUID`) — the identity the cross-token predicates compare /// (`UnitIsUnit`, `UnitInParty`; decision 0434 §5's popup gating). `0` = the app's feed didn't /// resolve one; two zero guids never compare equal. diff --git a/crates/benilla-ui/src/script/unit/tests.rs b/crates/benilla-ui/src/script/unit/tests.rs index 0b294f0e..7b8f3eb5 100644 --- a/crates/benilla-ui/src/script/unit/tests.rs +++ b/crates/benilla-ui/src/script/unit/tests.rs @@ -518,3 +518,49 @@ fn unit_race_class_sex_report_the_snapshot_or_the_absent_shape() { .eval::(r#"return UnitSex("target") == nil"#) .unwrap()); } + +/// `UnitFactionGroup` returns the (english, localized) pair the PvP-icon law reads, and `nil, nil` +/// for a unit with no side — the state the reference's `if ( factionGroup and … )` gate exists +/// for (decision 0646 §1). `TogglePVP` queues one toggle per call. +#[test] +fn faction_group_pair_and_the_pvp_toggle() { + let mut s = UiScript::new().unwrap(); + s.set_unit( + "target", + Some(UnitState { + exists: true, + faction_group: Some("Horde".into()), + ..Default::default() + }), + ); + let (english, localized) = s + .eval::<(String, String)>(r#"return UnitFactionGroup("target")"#) + .unwrap(); + assert_eq!((english.as_str(), localized.as_str()), ("Horde", "Horde")); + + // No side (a Monster/neutral template, or a unit whose template hasn't streamed). + s.set_unit( + "target", + Some(UnitState { + exists: true, + faction_group: None, + ..Default::default() + }), + ); + assert!(s + .eval::(r#"local a, b = UnitFactionGroup("target") return a == nil and b == nil"#) + .unwrap()); + // An absent unit answers the same way. + assert!(s + .eval::(r#"local a = UnitFactionGroup("focus") return a == nil"#) + .unwrap()); + + assert_eq!(s.take_pvp_toggles(), 0, "nothing queued yet"); + s.run("TogglePVP() TogglePVP()").unwrap(); + assert_eq!( + s.take_pvp_toggles(), + 2, + "one send per call, never collapsed" + ); + assert_eq!(s.take_pvp_toggles(), 0, "the drain empties the queue"); +} diff --git a/crates/benilla-ui/src/widget/kinds/editbox.rs b/crates/benilla-ui/src/widget/kinds/editbox.rs index 431dd32f..8d58508b 100644 --- a/crates/benilla-ui/src/widget/kinds/editbox.rs +++ b/crates/benilla-ui/src/widget/kinds/editbox.rs @@ -57,7 +57,9 @@ pub struct EditBoxState { /// default; ChatFrame's edit box declares 32). pub history_max: usize, /// The active recall position while browsing (an index into [`Self::history`]); `None` = live - /// editing. Any typed edit or `SetText` ends browsing. + /// editing. Typed edits, [`Self::add_history_line`], and focus gain end browsing — + /// programmatic `SetText` deliberately does NOT (the chat live parse rewrites the box on + /// every recalled slash line; ending the browse there pinned every UP to the newest entry). pub history_pos: Option, /// The live line stashed when browsing starts, restored when DOWN walks past the newest entry. pub history_draft: Option, @@ -183,8 +185,9 @@ impl EditBoxState { self.history_pos.map(|p| self.history[p].clone()) } - /// A typed edit or programmatic `SetText` ends any history browse (the recalled line becomes - /// an ordinary draft; the stash is dropped). + /// End any history browse (the recalled line becomes an ordinary draft; the stash is + /// dropped). Called on typed edits, [`Self::add_history_line`], and focus gain — never on + /// programmatic `SetText` (see [`Self::history_pos`]). pub fn end_history_browse(&mut self) { self.history_pos = None; self.history_draft = None; diff --git a/crates/benilla-ui/src/widget/mod.rs b/crates/benilla-ui/src/widget/mod.rs index dc40df9f..7e73ca72 100644 --- a/crates/benilla-ui/src/widget/mod.rs +++ b/crates/benilla-ui/src/widget/mod.rs @@ -232,6 +232,14 @@ pub struct Frame { /// instead of leaving the window) though its XML never sets the attribute — the class supplies /// the flag — and the rule here is absolute: no tooltip ever leaves the window (decision 0352). pub clamped_to_screen: bool, + /// `SetHitRectInsets` / XML `` — how far the frame's **mouse** rect is inset + /// from its resolved rect, as `[left, right, top, bottom]` (default all 0, i.e. the hit rect + /// IS the frame rect). Only [`crate::order::hit_test`] reads it: drawing, anchoring, and + /// `GetLeft`/`GetWidth` all stay on the un-inset rect. The reference uses it wherever a + /// button's art fills less than its frame — the micro buttons are 29×58 frames whose art + /// occupies only the lower ~40 px, and a `top="18"` inset is what stops that empty header + /// from swallowing hover and clicks aimed past the bar. + pub hit_rect_insets: [f32; 4], /// This frame's own scale (`ownScale +0xb8`, default 1.0). pub scale: f32, /// `layoutScale` = `parentEffective * ownScale`, ε-gated (`effective_scale 0x76ac90`). @@ -452,6 +460,9 @@ impl WidgetArena { // yet the reference plate observably never leaves the window — the class supplies // geometry flags bit4; decision 0352's law: no tooltip off-screen, ever). clamped_to_screen: matches!(kind, FrameKind::GameTooltip), + // No frame is born with an inset hit rect — ``/SetHitRectInsets is the + // only source (the client's ctor zeroes the four floats too). + hit_rect_insets: [0.0; 4], scale, effective_scale: parent_scale * scale, ignore_parent_scale: false, diff --git a/crates/benilla-ui/src/widget/propagation.rs b/crates/benilla-ui/src/widget/propagation.rs index cdff660e..2e0bf42b 100644 --- a/crates/benilla-ui/src/widget/propagation.rs +++ b/crates/benilla-ui/src/widget/propagation.rs @@ -264,6 +264,22 @@ impl WidgetArena { self.frame(h).map(|f| f.clamped_to_screen).unwrap_or(false) } + // ── Hit-rect insets (the mouse rect, not the draw rect) ────────────────────────────────────── + + /// Set a frame's hit-rect insets, `[left, right, top, bottom]` (`SetHitRectInsets`; see + /// [`Frame::hit_rect_insets`]). A stale handle is a no-op. + pub fn set_hit_rect_insets(&mut self, h: FrameHandle, insets: [f32; 4]) { + if let Some(f) = self.frame_mut(h) { + f.hit_rect_insets = insets; + } + } + + /// A frame's hit-rect insets, `[left, right, top, bottom]` (`GetHitRectInsets`). A stale handle + /// reads as all-zero — the same "no inset" answer an untouched frame gives. + pub fn hit_rect_insets(&self, h: FrameHandle) -> [f32; 4] { + self.frame(h).map(|f| f.hit_rect_insets).unwrap_or([0.0; 4]) + } + // ── Small helpers ──────────────────────────────────────────────────────────────────────────── /// Is `maybe_ancestor` on the parent chain of `node` (walking up, loop-guarded)? diff --git a/crates/benilla-visual/src/lib.rs b/crates/benilla-visual/src/lib.rs index 8f8f923f..08ed4676 100644 --- a/crates/benilla-visual/src/lib.rs +++ b/crates/benilla-visual/src/lib.rs @@ -4,10 +4,12 @@ //! in the `benilla` crate) and diffs them. This crate is the pure-math half: given two equally-sized //! images it reports how far they diverge ([`Metrics`]) and renders a heatmap of *where* ([`diff_image`]). //! -//! Two uses, one tool: (1) **self-regression** — capture baselines on the current pipeline, then diff +//! Three uses, one tool: (1) **self-regression** — capture baselines on the current pipeline, then diff //! every linear-HDR rework step against them so a machine catches a regression before the director's //! eye; (2) **determinism check** — diffing two captures of the same scenario must come out ≈0, which is -//! what makes (1) trustworthy. +//! what makes (1) trustworthy; (3) **flicker** ([`envelope`]) — the same arithmetic over *time* instead +//! of over versions: a burst of adjacent frames from a parked camera (`WOW_LIVE_SHOT_COUNT`), collapsed +//! to where the picture would not hold still. use image::RgbImage; @@ -88,6 +90,443 @@ pub fn diff_image(a: &RgbImage, b: &RgbImage, amplify: u32) -> anyhow::Result anyhow::Result { + let (w, h) = burst_dimensions(frames, 2)?; + let mut image = RgbImage::new(w, h); + let mut sum_swing = 0u64; + let mut max_swing = 0u8; + let mut unstable = 0u64; + for (x, y, out) in image.enumerate_pixels_mut() { + let mut pixel_max = 0u8; + for c in 0..3 { + let (mut lo, mut hi) = (u8::MAX, u8::MIN); + for f in frames { + let v = f.get_pixel(x, y)[c]; + lo = lo.min(v); + hi = hi.max(v); + } + let swing = hi - lo; + sum_swing += swing as u64; + max_swing = max_swing.max(swing); + pixel_max = pixel_max.max(swing); + out[c] = (swing as u32 * amplify).min(255) as u8; + } + if pixel_max > OVER_THRESHOLD { + unstable += 1; + } + } + let n_pixels = (w as u64) * (h as u64); + Ok(Envelope { + image, + max_swing, + mean_swing: sum_swing as f64 / (n_pixels * 3).max(1) as f64, + pct_unstable: unstable as f64 / n_pixels.max(1) as f64, + }) +} + +/// How often a burst *reversed direction*, and where — the output of [`toggles`]. +#[derive(Debug, Clone)] +pub struct Toggles { + /// Per-channel reversal count across the stack, amplified — the picture of *where* it toggles. + pub image: RgbImage, + /// Most reversals any one channel made (0..frames−2). + pub max_reversals: u32, + /// Fraction of pixels (0..1) that reversed at least [`TOGGLE_MIN_REVERSALS`] times. + pub pct_toggling: f64, + /// Row-major `width × height` flags: did this pixel toggle? Carried out of the scan because + /// *where* the toggling pixels sit relative to each other is a separate reading ([`shape`]) and + /// re-thresholding the amplified image would only recover it approximately. + pub mask: Vec, +} + +/// A pixel counts as "toggling" for [`Toggles::pct_toggling`] at this many direction reversals. Two +/// is the smallest count that cannot be a single overshoot, and it already needs a four-frame +/// up-down-up run — which smooth motion across an edge does not produce. +pub const TOGGLE_MIN_REVERSALS: u32 = 2; + +/// Count, per pixel and channel, how many times the value **reversed direction** across the burst, +/// ignoring steps smaller than `min_delta`. +/// +/// This is [`envelope`]'s companion for the case the envelope cannot serve: **a moving camera**. +/// When the view is panning, every edge in the frame sweeps across pixels and `max − min` lights up +/// the whole picture, so it says nothing. But smooth motion is *monotone* — a pixel being crossed by +/// an edge goes one way and stays going that way — while z-fighting and an unstable draw order +/// **alternate**, A/B/A/B, however the camera moves. Counting reversals separates the two. +/// +/// The director's report is what forced this: the Far Watch Post tower "mostly only flickers while +/// moving the cam, not while still" — the one condition under which a parked burst is blind by +/// construction (decision 0653's blind spot, named in 0656). +pub fn toggles(frames: &[RgbImage], min_delta: u8, amplify: u32) -> anyhow::Result { + let (w, h) = burst_dimensions(frames, 3)?; + let mut image = RgbImage::new(w, h); + let mut max_reversals = 0u32; + let mut toggling = 0u64; + let mut mask = vec![false; (w as usize) * (h as usize)]; + for (x, y, out) in image.enumerate_pixels_mut() { + let mut pixel_max = 0u32; + for c in 0..3 { + // `dir` is the sign of the last step big enough to count; a run of sub-threshold steps + // neither reverses nor resets it, so dithering noise cannot manufacture a toggle. + let (mut dir, mut reversals) = (0i8, 0u32); + for pair in frames.windows(2) { + let (a, b) = ( + pair[0].get_pixel(x, y)[c] as i32, + pair[1].get_pixel(x, y)[c] as i32, + ); + let step = b - a; + if step.unsigned_abs() < min_delta as u32 { + continue; + } + let sign = if step > 0 { 1i8 } else { -1i8 }; + if dir != 0 && sign != dir { + reversals += 1; + } + dir = sign; + } + max_reversals = max_reversals.max(reversals); + pixel_max = pixel_max.max(reversals); + out[c] = (reversals * amplify).min(255) as u8; + } + if pixel_max >= TOGGLE_MIN_REVERSALS { + toggling += 1; + mask[(y as usize) * (w as usize) + x as usize] = true; + } + } + let n_pixels = (w as u64) * (h as u64); + Ok(Toggles { + image, + max_reversals, + pct_toggling: toggling as f64 / n_pixels.max(1) as f64, + mask, + }) +} + +/// A rectangle of the frame, in pixels — `x1`/`y1` exclusive. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rect { + pub x0: u32, + pub y0: u32, + pub x1: u32, + pub y1: u32, +} + +impl Rect { + pub fn width(&self) -> u32 { + self.x1 - self.x0 + } + pub fn height(&self) -> u32 { + self.y1 - self.y0 + } + /// Grow by `pad` on every side, clamped to a `w × h` frame. + pub fn padded(&self, pad: u32, w: u32, h: u32) -> Rect { + Rect { + x0: self.x0.saturating_sub(pad), + y0: self.y0.saturating_sub(pad), + x1: (self.x1 + pad).min(w), + y1: (self.y1 + pad).min(h), + } + } +} + +/// One connected run of toggling pixels. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Region { + pub bounds: Rect, + /// Toggling pixels in the run (≤ the bounds' area). + pub pixels: u64, + /// Up to [`REGION_SAMPLES`] pixels spread evenly through the run, in scan order — coordinates + /// Every pixel in the run, in scan order. Kept whole because the run's *own* time series + /// ([`Region::steps`]) is the reading that follows, and it has to be over the run, not over a + /// bounding box that also contains whatever else was passing through. + pub members: Vec<(u32, u32)>, +} + +/// How many representative pixels [`Region::samples`] hands back. +pub const REGION_SAMPLES: usize = 8; + +/// One frame-to-frame step of a run, from [`Region::steps`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Step { + /// The run's mean luma (0..255) in the earlier frame. The *level*, not just the change: a + /// two-state flip's ratio is what identifies the multiplier behind it, and a delta alone hides it. + pub mean_from: f64, + /// The run's mean R/G/B (0..255) in the earlier frame. Whether a two-state flip preserves the + /// channel ratios says what kind of flip it is: a pure **intensity** scale (one light term + /// switching on or off) keeps the hue, while swapping to a differently-coloured lighting lane + /// does not — and luma alone cannot tell those apart. + pub mean_rgb_from: [f64; 3], + /// Change in the run's mean luma (0..255 units) from this frame to the next. + pub mean_delta: f64, + /// Fraction of the run's pixels (0..1) that moved the **same way** as the mean did. Near 1 = the + /// whole surface changed together; near 0.5 = as many pixels went up as down. + pub agreement: f64, +} + +impl Region { + /// How much of its own bounding box the run actually fills, 0..1. A solid surface approaches 1; + /// a scattering of speckles inside a wide box is near 0. + pub fn fill(&self) -> f64 { + let area = u64::from(self.bounds.width()) * u64::from(self.bounds.height()); + self.pixels as f64 / area.max(1) as f64 + } + + /// Up to [`REGION_SAMPLES`] pixels spread evenly through the run — coordinates that are + /// *provably* in it, to hand to the in-game ray pick (`WOW_PICK`). Reading them off a heatmap by + /// eye lands next to the run as often as in it, and then the pick names the wrong surface with + /// total confidence. + pub fn samples(&self) -> Vec<(u32, u32)> { + let step = (self.members.len() / REGION_SAMPLES).max(1); + self.members + .iter() + .copied() + .step_by(step) + .take(REGION_SAMPLES) + .collect() + } + + /// The run's own frame-to-frame time series — the reading that separates a surface being + /// **re-shaded** from one whose **edges are moving**. + /// + /// [`shape`] says the toggling pixels form one coherent surface; it cannot say what the surface + /// is doing. If the whole run brightens and dims together (`agreement` near 1, `mean_delta` + /// swinging), it is being lit differently frame to frame — a shading defect. If half its pixels + /// go up while the other half go down (`agreement` near 0.5, `mean_delta` near 0), nothing is + /// being re-lit: a boundary is sweeping across it, and the run is an edge, not a surface. + pub fn steps(&self, frames: &[RgbImage]) -> Vec { + frames + .windows(2) + .map(|pair| { + let mut from = 0.0; + let mut rgb = [0.0f64; 3]; + let deltas: Vec = self + .members + .iter() + .map(|&(x, y)| { + let p = pair[0].get_pixel(x, y); + for c in 0..3 { + rgb[c] += f64::from(p[c]); + } + let a = luma(p); + from += a; + luma(pair[1].get_pixel(x, y)) - a + }) + .collect(); + let n = deltas.len().max(1) as f64; + let mean_delta = deltas.iter().sum::() / n; + // A pixel that did not move is counted as disagreeing: it is evidence against "the + // whole surface moved together", which is exactly the claim under test. + let same = deltas + .iter() + .filter(|d| **d != 0.0 && d.is_sign_positive() == mean_delta.is_sign_positive()) + .count(); + Step { + mean_from: from / n, + mean_rgb_from: [rgb[0] / n, rgb[1] / n, rgb[2] / n], + mean_delta, + agreement: same as f64 / n, + } + }) + .collect() + } +} + +/// Rec. 601 luma — one number per pixel for "how bright", so a colour shift and a brightness shift +/// are not confused with each other. +fn luma(p: &image::Rgb) -> f64 { + 0.299 * f64::from(p[0]) + 0.587 * f64::from(p[1]) + 0.114 * f64::from(p[2]) +} + +/// The **spatial** structure of a toggle map — the reading that separates two defects the toggle +/// percentage alone cannot tell apart. +/// +/// Both z-fighting and a visibility flip make pixels alternate, so both score the same on +/// [`Toggles::pct_toggling`]. They differ in *shape*: z-fighting resolves per fragment, so it lands +/// as moiré banding or speckle — many small runs, low neighbour agreement. A whole surface blinking +/// in and out (a culling, portal-visibility or draw-submission flip) lands as **one large solid run** +/// with the surface's own silhouette — few runs, high fill, high coherence. That distinction decides +/// which subsystem is at fault, so it is measured rather than eyeballed off a heatmap. +#[derive(Debug, Clone, PartialEq)] +pub struct Shape { + /// Connected runs of at least [`MIN_REGION_PIXELS`], largest first. + pub regions: Vec, + /// Toggling pixels that fell in runs too small to list. + pub scattered: u64, + /// Mean fraction of the 4-neighbours of a toggling pixel that also toggle, 0..1. Near 1 = solid + /// areas; near 0 = isolated pixels. A one-pixel-wide moiré band sits near 0.5. + pub coherence: f64, +} + +/// A connected run smaller than this is noise, not a surface — counted in [`Shape::scattered`]. +pub const MIN_REGION_PIXELS: u64 = 16; + +/// Group a toggle mask into connected runs (4-connectivity) and measure their coherence. +pub fn shape(t: &Toggles) -> Shape { + let (w, h) = t.image.dimensions(); + let (wu, hu) = (w as usize, h as usize); + let idx = |x: usize, y: usize| y * wu + x; + + let mut coherent = 0u64; + let mut toggling = 0u64; + for y in 0..hu { + for x in 0..wu { + if !t.mask[idx(x, y)] { + continue; + } + toggling += 1; + // Off-frame neighbours count as non-toggling: an edge pixel is genuinely less enclosed, + // and pretending otherwise would inflate the coherence of anything touching the border. + let n = u64::from(x > 0 && t.mask[idx(x - 1, y)]) + + u64::from(x + 1 < wu && t.mask[idx(x + 1, y)]) + + u64::from(y > 0 && t.mask[idx(x, y - 1)]) + + u64::from(y + 1 < hu && t.mask[idx(x, y + 1)]); + coherent += n; + } + } + + // Iterative flood fill — a recursive one blows the stack on a full-screen region. + let mut seen = vec![false; wu * hu]; + let mut regions: Vec = Vec::new(); + let mut scattered = 0u64; + let mut stack: Vec<(usize, usize)> = Vec::new(); + for y0 in 0..hu { + for x0 in 0..wu { + if !t.mask[idx(x0, y0)] || seen[idx(x0, y0)] { + continue; + } + seen[idx(x0, y0)] = true; + stack.push((x0, y0)); + let (mut pixels, mut b) = ( + 0u64, + Rect { + x0: x0 as u32, + y0: y0 as u32, + x1: x0 as u32 + 1, + y1: y0 as u32 + 1, + }, + ); + let mut members: Vec<(u32, u32)> = Vec::new(); + while let Some((x, y)) = stack.pop() { + pixels += 1; + members.push((x as u32, y as u32)); + b.x0 = b.x0.min(x as u32); + b.y0 = b.y0.min(y as u32); + b.x1 = b.x1.max(x as u32 + 1); + b.y1 = b.y1.max(y as u32 + 1); + let mut push = |nx: usize, ny: usize, stack: &mut Vec<(usize, usize)>| { + if t.mask[idx(nx, ny)] && !seen[idx(nx, ny)] { + seen[idx(nx, ny)] = true; + stack.push((nx, ny)); + } + }; + if x > 0 { + push(x - 1, y, &mut stack); + } + if x + 1 < wu { + push(x + 1, y, &mut stack); + } + if y > 0 { + push(x, y - 1, &mut stack); + } + if y + 1 < hu { + push(x, y + 1, &mut stack); + } + } + if pixels >= MIN_REGION_PIXELS { + // The flood fill visits in stack order, so sort to scan order — otherwise + // `Region::samples` spreads its picks through the traversal, not through the run. + members.sort_unstable_by_key(|&(x, y)| (y, x)); + regions.push(Region { + bounds: b, + pixels, + members, + }); + } else { + scattered += pixels; + } + } + } + regions.sort_by_key(|r| std::cmp::Reverse(r.pixels)); + Shape { + regions, + scattered, + coherence: coherent as f64 / (toggling.max(1) * 4) as f64, + } +} + +/// Cut `rect` out of `img`, clamped to its bounds. +pub fn crop(img: &RgbImage, rect: Rect) -> RgbImage { + let r = rect.padded(0, img.width(), img.height()); + let (w, h) = (r.width().max(1), r.height().max(1)); + RgbImage::from_fn(w, h, |x, y| { + *img.get_pixel( + (r.x0 + x).min(img.width() - 1), + (r.y0 + y).min(img.height() - 1), + ) + }) +} + +/// Lay tiles out left-to-right, top-to-bottom in a `cols`-wide grid on a mid-grey mat, so a burst's +/// crops read as one contact sheet. Tiles of different sizes are placed at their own size. +pub fn contact_strip(tiles: &[RgbImage], cols: u32, gap: u32) -> RgbImage { + let cols = cols.max(1); + let (tw, th) = tiles + .iter() + .fold((1, 1), |(w, h), t| (w.max(t.width()), h.max(t.height()))); + let rows = (tiles.len() as u32).div_ceil(cols); + let out_w = cols * tw + (cols + 1) * gap; + let out_h = rows * th + (rows + 1) * gap; + let mut out = RgbImage::from_pixel(out_w, out_h, image::Rgb([32, 32, 32])); + for (i, tile) in tiles.iter().enumerate() { + let (cx, cy) = (i as u32 % cols, i as u32 / cols); + let (ox, oy) = (gap + cx * (tw + gap), gap + cy * (th + gap)); + for (x, y, p) in tile.enumerate_pixels() { + if ox + x < out_w && oy + y < out_h { + out.put_pixel(ox + x, oy + y, *p); + } + } + } + out +} + +/// The common size of a burst, erroring unless it has at least `min` equally-sized frames. +fn burst_dimensions(frames: &[RgbImage], min: usize) -> anyhow::Result<(u32, u32)> { + if frames.len() < min { + anyhow::bail!("needs at least {min} frames, got {}", frames.len()); + } + let (w, h) = frames[0].dimensions(); + for (i, f) in frames.iter().enumerate() { + if f.dimensions() != (w, h) { + anyhow::bail!( + "frame {i} is {:?}, expected {:?} — a burst must be one camera, one window size", + f.dimensions(), + (w, h) + ); + } + } + Ok((w, h)) +} + /// Stitch two images side by side (`left | right`) with a `gap`-px dark separator, for at-a-glance A/B /// comparison (e.g. the faithful vs modern render). Heights may differ; the output is the max height /// with each image top-aligned. @@ -177,6 +616,128 @@ mod tests { assert_eq!(d2.get_pixel(0, 0), &Rgb([255, 0, 0])); } + #[test] + fn a_still_burst_has_no_envelope() { + // The point of max−min: a scene that never moves comes out black however bright it is, so a + // merely *wrong* render cannot masquerade as a flickering one. + let img = solid(4, 4, [200, 30, 90]); + let e = envelope(&[img.clone(), img.clone(), img], 8).unwrap(); + assert_eq!(e.max_swing, 0); + assert_eq!(e.mean_swing, 0.0); + assert_eq!(e.pct_unstable, 0.0); + assert_eq!(e.image.get_pixel(0, 0), &Rgb([0, 0, 0])); + } + + #[test] + fn envelope_spans_the_whole_stack_not_just_neighbours() { + // 10 → 60 → 10: consecutive pairs each swing 50, and so does the envelope. But a value that + // drifts monotonically (10 → 35 → 60) swings only 25 per pair while the envelope still says + // 50 — which is the reason this is a stack operation and not a chain of pairwise diffs. + let base = solid(2, 1, [10, 10, 10]); + let alternating = { + let mut m = base.clone(); + m.put_pixel(0, 0, Rgb([60, 10, 10])); + m + }; + let drifting = { + let mut m = base.clone(); + m.put_pixel(0, 0, Rgb([35, 10, 10])); + m + }; + for stack in [ + [base.clone(), alternating.clone(), base.clone()], + [base.clone(), drifting, alternating], + ] { + let e = envelope(&stack, 1).unwrap(); + assert_eq!(e.max_swing, 50); + assert_eq!(e.image.get_pixel(0, 0), &Rgb([50, 0, 0])); + assert_eq!(e.image.get_pixel(1, 0), &Rgb([0, 0, 0])); + // one unstable pixel of two + assert!((e.pct_unstable - 0.5).abs() < 1e-9); + } + } + + #[test] + fn envelope_amplifies_and_clamps_like_the_diff() { + let a = solid(1, 1, [10, 10, 10]); + let b = solid(1, 1, [20, 10, 10]); + assert_eq!( + envelope(&[a.clone(), b.clone()], 8) + .unwrap() + .image + .get_pixel(0, 0), + &Rgb([80, 0, 0]) + ); + assert_eq!( + envelope(&[a, b], 32).unwrap().image.get_pixel(0, 0), + &Rgb([255, 0, 0]) + ); + } + + #[test] + fn envelope_needs_a_burst_and_one_window_size() { + let a = solid(4, 4, [0, 0, 0]); + assert!(envelope(&[], 8).is_err()); + assert!(envelope(std::slice::from_ref(&a), 8).is_err()); + assert!(envelope(&[a, solid(4, 5, [0, 0, 0])], 8).is_err()); + } + + /// One red value per frame, at pixel (0,0) of a 1×1 burst. + fn ramp(reds: &[u8]) -> Vec { + reds.iter() + .map(|r| RgbImage::from_pixel(1, 1, Rgb([*r, 0, 0]))) + .collect() + } + + #[test] + fn a_monotone_sweep_never_toggles_however_far_it_moves() { + // The whole point: this is what a moving camera does to a pixel an edge sweeps across — + // a huge envelope (0 → 240) and zero reversals. `envelope` calls it maximally unstable; + // `toggles` correctly says it is not flickering. + let sweep = ramp(&[0, 60, 120, 180, 240]); + assert_eq!(envelope(&sweep, 1).unwrap().max_swing, 240); + let t = toggles(&sweep, 4, 60).unwrap(); + assert_eq!(t.max_reversals, 0); + assert_eq!(t.pct_toggling, 0.0); + } + + #[test] + fn an_alternating_pixel_toggles_every_step() { + // A/B/A/B/A — z-fighting's signature. Four steps, three of them reversals. + let t = toggles(&ramp(&[10, 90, 10, 90, 10]), 4, 60).unwrap(); + assert_eq!(t.max_reversals, 3); + assert_eq!(t.pct_toggling, 1.0); + assert_eq!(t.image.get_pixel(0, 0), &Rgb([180, 0, 0])); // 3 × 60, unclamped + } + + #[test] + fn sub_threshold_noise_neither_toggles_nor_forgets_the_direction() { + // ±1 dither must not manufacture reversals … + let t = toggles(&ramp(&[50, 51, 50, 51, 50, 51]), 4, 60).unwrap(); + assert_eq!(t.max_reversals, 0); + // … and a run of it in the middle of a real climb must not reset the direction either, + // or every noisy edge would read as one reversal per crossing. + let t = toggles(&ramp(&[0, 40, 41, 40, 41, 80]), 4, 60).unwrap(); + assert_eq!(t.max_reversals, 0); + } + + #[test] + fn a_single_overshoot_is_one_reversal_and_below_the_toggle_bar() { + // Down-then-up once: real, but not a flicker — `TOGGLE_MIN_REVERSALS` is what draws that + // line, so a lone direction change must not be counted as a toggling pixel. + let t = toggles(&ramp(&[10, 90, 40]), 4, 60).unwrap(); + assert_eq!(t.max_reversals, 1); + assert_eq!(t.pct_toggling, 0.0); + } + + #[test] + fn toggles_needs_three_frames_and_one_window_size() { + let a = solid(4, 4, [0, 0, 0]); + assert!(toggles(&[a.clone(), a.clone()], 4, 60).is_err()); + assert!(toggles(&[a.clone(), a.clone(), solid(4, 5, [0, 0, 0])], 4, 60).is_err()); + assert!(toggles(&[a.clone(), a.clone(), a], 4, 60).is_ok()); + } + #[test] fn compose_places_both_with_gap() { let l = solid(3, 2, [10, 20, 30]); @@ -187,4 +748,138 @@ mod tests { assert_eq!(out.get_pixel(3, 0), &Rgb([24, 24, 24])); // separator assert_eq!(out.get_pixel(4, 0), &Rgb([40, 50, 60])); // right image } + + /// A burst in which exactly the pixels selected by `flips` alternate 0/200 and everything else + /// holds still — the shape tests differ only in *which* pixels those are. + fn alternating( + w: u32, + h: u32, + frames: usize, + flips: impl Fn(u32, u32) -> bool, + ) -> Vec { + (0..frames) + .map(|i| { + RgbImage::from_fn(w, h, |x, y| { + let on = flips(x, y) && i % 2 == 1; + Rgb([if on { 200 } else { 0 }, 0, 0]) + }) + }) + .collect() + } + + #[test] + fn one_solid_block_reads_as_a_single_filled_run() { + // A whole surface blinking: one run, filling its own box, every interior pixel enclosed. + let frames = alternating(20, 20, 6, |x, y| { + (4..12).contains(&x) && (4..12).contains(&y) + }); + let s = shape(&toggles(&frames, 4, 60).unwrap()); + assert_eq!(s.regions.len(), 1); + assert_eq!(s.regions[0].pixels, 64); + assert_eq!( + s.regions[0].bounds, + Rect { + x0: 4, + y0: 4, + x1: 12, + y1: 12 + } + ); + assert!((s.regions[0].fill() - 1.0).abs() < 1e-9); + assert_eq!(s.scattered, 0); + // 8x8 block: 4 corners see 2 neighbours, 24 edges see 3, 36 interior see 4 → 224/256. + assert!( + (s.coherence - 224.0 / 256.0).abs() < 1e-9, + "{}", + s.coherence + ); + } + + #[test] + fn a_checkerboard_covers_the_same_area_with_none_of_the_shape() { + // Z-fighting resolving per fragment. Same region of the frame, ~half the pixels toggling, + // but no pixel touches another — the reading that separates it from the block above. + let frames = alternating(20, 20, 6, |x, y| { + (4..12).contains(&x) && (4..12).contains(&y) && (x + y) % 2 == 0 + }); + let s = shape(&toggles(&frames, 4, 60).unwrap()); + assert_eq!(s.coherence, 0.0); + assert!(s.regions.is_empty(), "isolated pixels are not runs"); + assert_eq!(s.scattered, 32); + } + + #[test] + fn runs_come_back_largest_first_and_short_ones_are_scattered() { + // Two blocks plus a speck: the big one leads, the speck falls below MIN_REGION_PIXELS. + let frames = alternating(40, 20, 6, |x, y| { + let big = (2..12).contains(&x) && (2..12).contains(&y); // 100 px + let small = (20..25).contains(&x) && (2..7).contains(&y); // 25 px + let speck = (30..33).contains(&x) && y == 10; // 3 px — noise + big || small || speck + }); + let s = shape(&toggles(&frames, 4, 60).unwrap()); + assert_eq!( + s.regions.iter().map(|r| r.pixels).collect::>(), + vec![100, 25] + ); + assert_eq!(s.scattered, 3); + } + + #[test] + fn a_still_burst_has_no_shape_at_all() { + let frames = alternating(8, 8, 6, |_, _| false); + let s = shape(&toggles(&frames, 4, 60).unwrap()); + assert!(s.regions.is_empty()); + assert_eq!(s.scattered, 0); + assert_eq!(s.coherence, 0.0); // no toggling pixels — not a divide-by-zero + } + + #[test] + fn crop_takes_the_named_rect() { + let img = RgbImage::from_fn(10, 10, |x, y| Rgb([x as u8, y as u8, 0])); + let out = crop( + &img, + Rect { + x0: 3, + y0: 4, + x1: 6, + y1: 9, + }, + ); + assert_eq!(out.dimensions(), (3, 5)); + assert_eq!(out.get_pixel(0, 0), &Rgb([3, 4, 0])); + assert_eq!(out.get_pixel(2, 4), &Rgb([5, 8, 0])); + } + + #[test] + fn padding_a_rect_stops_at_the_frame_edge() { + let r = Rect { + x0: 2, + y0: 2, + x1: 5, + y1: 5, + }; + assert_eq!( + r.padded(10, 8, 8), + Rect { + x0: 0, + y0: 0, + x1: 8, + y1: 8 + } + ); + } + + #[test] + fn a_contact_strip_lays_tiles_out_in_a_grid() { + let tiles: Vec = (0..3) + .map(|i| RgbImage::from_pixel(2, 2, Rgb([10 * (i + 1) as u8, 0, 0]))) + .collect(); + let out = contact_strip(&tiles, 2, 1); + assert_eq!(out.dimensions(), (2 * 2 + 3, 2 * 2 + 3)); // 2 cols, 2 rows, 1px mat + assert_eq!(out.get_pixel(1, 1), &Rgb([10, 0, 0])); // tile 0 + assert_eq!(out.get_pixel(4, 1), &Rgb([20, 0, 0])); // tile 1, next column + assert_eq!(out.get_pixel(1, 4), &Rgb([30, 0, 0])); // tile 2, wrapped + assert_eq!(out.get_pixel(4, 4), &Rgb([32, 32, 32])); // empty cell stays mat + } } diff --git a/crates/benilla-visual/src/main.rs b/crates/benilla-visual/src/main.rs index 0290e53a..c1ad1b0f 100644 --- a/crates/benilla-visual/src/main.rs +++ b/crates/benilla-visual/src/main.rs @@ -3,21 +3,54 @@ //! Usage: //! benilla-visual diff [--out ] [--fail ] [--amplify ] //! benilla-visual diff-dir [--out ] [--fail ] [--amplify ] +//! benilla-visual flicker [--out ] [--fail ] [--amplify ] //! //! `diff` compares two images; `diff-dir` compares every `*.png` present in *both* directories by name. //! Prints the metrics; writes amplified heatmap(s) when `--out` is given; exits non-zero if any image's //! MAE exceeds `--fail` (when given). Typical loop: capture baselines, change the renderer, re-capture, //! `diff-dir baseline candidate --out diff --fail 1.5`. +//! +//! `flicker` is the same arithmetic over *time*: point it at a `WOW_LIVE_SHOT_COUNT` burst (adjacent +//! frames, parked camera) and it prints the frame-to-frame table and the whole-stack envelope, and +//! writes the envelope heatmap — the picture of which pixels would not hold still. use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; -use benilla_visual::{compare, compose_side_by_side, diff_image, Metrics, OVER_THRESHOLD}; +use benilla_visual::{ + compare, compose_side_by_side, contact_strip, crop, diff_image, envelope, shape, toggles, + Metrics, OVER_THRESHOLD, +}; + +/// A frame-to-frame step must move a channel by this much to count as a direction change (override +/// with `--toggle-delta`). Above PNG/dither noise, below any real surface swap. +/// +/// **The pan rate matters more than this number.** A moving camera sweeps texture detail across +/// every pixel, and detail is not monotone — at 8°/s (≈15 px/frame at 3200 px) the map saturates at +/// 93% and says nothing. Keep the sweep **sub-pixel per frame** (≈0.2°/s) so ordinary surfaces +/// barely move while a depth-comparison flip still swaps whole surfaces at full amplitude. +const TOGGLE_MIN_DELTA: u8 = 4; + +/// Reversal counts are small integers, so the toggle map needs its own (much larger) gain than the +/// 0..255 swings the envelope amplifies: at 60, three reversals saturate. +const TOGGLE_AMPLIFY: u32 = 60; /// Gap (px) between the two halves of a side-by-side compose. const COMPOSE_GAP: u32 = 6; +/// Tiles per row on a `hotspot` contact sheet — wide enough that a 24-frame burst reads as a few +/// rows of adjacent frames rather than one unscannable ribbon. +const STRIP_COLS: u32 = 6; + +/// Pixels of context kept around a hotspot crop (override with `--pad`). Enough to see what the +/// toggling region borders — which is usually the whole point. +const DEFAULT_PAD: u32 = 24; + +/// How many runs `hotspot` reports a time series for. Enough to see whether the top runs flip on the +/// same frames (one cause) or independently (several), narrow enough to stay one screen. +const SERIES_RUNS: usize = 4; + /// Default amplification for the heatmap output (per-channel abs-diff ×N, clamped). const DEFAULT_AMPLIFY: u32 = 8; @@ -25,6 +58,8 @@ struct Opts { out: Option, fail: Option, amplify: u32, + toggle_delta: u8, + pad: u32, } fn main() -> Result<()> { @@ -34,6 +69,8 @@ fn main() -> Result<()> { out: None, fail: None, amplify: DEFAULT_AMPLIFY, + toggle_delta: TOGGLE_MIN_DELTA, + pad: DEFAULT_PAD, }; let mut it = args.iter(); while let Some(a) = it.next() { @@ -46,11 +83,21 @@ fn main() -> Result<()> { .context("--fail not a number")?, ) } + "--toggle-delta" => { + opts.toggle_delta = next(&mut it, "--toggle-delta")? + .parse() + .context("--toggle-delta not a 0..255 integer")? + } "--amplify" => { opts.amplify = next(&mut it, "--amplify")? .parse() .context("--amplify not an integer")? } + "--pad" => { + opts.pad = next(&mut it, "--pad")? + .parse() + .context("--pad not an integer")? + } "-h" | "--help" => { print_usage(); return Ok(()); @@ -96,6 +143,30 @@ fn main() -> Result<()> { } } } + "flicker" => { + let [dir] = one(rest, "flicker")?; + let worst = flicker( + Path::new(dir), + opts.out.as_deref(), + opts.amplify, + opts.toggle_delta, + )?; + if over_fail(&worst, opts.fail) { + bail!( + "frame-to-frame MAE {:.3} exceeds --fail {:.3}", + worst.mae, + opts.fail.unwrap() + ); + } + } + "hotspot" => { + let [dir] = one(rest, "hotspot")?; + let out = opts + .out + .as_deref() + .context("hotspot needs --out ")?; + hotspot(Path::new(dir), out, opts.toggle_delta, opts.pad)?; + } "compose-dir" => { let [da, db] = two(rest, "compose-dir")?; let out = opts @@ -135,6 +206,200 @@ fn compose_dir(da: &Path, db: &Path, out: &Path) -> Result<()> { Ok(()) } +/// Report how far a burst of adjacent frames moved: the frame-to-frame table, then the whole-stack +/// envelope. Returns the worst consecutive-pair metrics (the `--fail` subject) — the envelope is a +/// *stack* number and can exceed any single pair, so failing on the pair is the conservative gate. +fn flicker(dir: &Path, out: Option<&Path>, amplify: u32, toggle_delta: u8) -> Result { + let names: Vec = pngs(dir)?.into_iter().collect(); // BTreeSet — already shot order + if names.len() < 2 { + bail!( + "{} holds {} *.png — a flicker burst needs at least 2 (WOW_LIVE_SHOT_COUNT=)", + dir.display(), + names.len() + ); + } + let frames: Vec = names + .iter() + .map(|n| load(&dir.join(n))) + .collect::>()?; + + let mut worst = Metrics { + mae: 0.0, + rmse: 0.0, + max_delta: 0, + pct_over: 0.0, + }; + for (pair, window) in frames.windows(2).enumerate() { + let m = compare(&window[0], &window[1])?; + println!( + "{:<24} {}", + format!("{} -> {}", names[pair], names[pair + 1]), + fmt_metrics(&m) + ); + if m.mae > worst.mae { + worst = m; + } + } + + let e = envelope(&frames, amplify)?; + println!( + "envelope of {} frames: max swing {:>3} mean {:>6.3} unstable pixels {:>6.2}%", + frames.len(), + e.max_swing, + e.mean_swing, + e.pct_unstable * 100.0 + ); + // The moving-camera reading. Printed always, because which of the two numbers is meaningful + // depends on whether the camera was parked — and a burst does not carry that fact. + let t = toggles(&frames, toggle_delta, TOGGLE_AMPLIFY)?; + println!( + "toggles of {} frames (delta {toggle_delta}): max reversals {:>3} toggling pixels {:>6.2}% \ + (the moving-camera reading — a monotone sweep scores 0)", + frames.len(), + t.max_reversals, + t.pct_toggling * 100.0 + ); + if let Some(out) = out { + if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).ok(); + } + e.image + .save(out) + .with_context(|| format!("writing envelope {}", out.display()))?; + println!("envelope -> {}", out.display()); + let toggle_out = sibling(out, "-toggle"); + t.image + .save(&toggle_out) + .with_context(|| format!("writing toggle map {}", toggle_out.display()))?; + println!("toggles -> {}", toggle_out.display()); + } + Ok(worst) +} + +/// Follow the toggle map to the thing it found: measure the toggling pixels' **shape**, then crop +/// the largest run out of every frame into one contact sheet. +/// +/// The map says *where*; this says *what kind*, and shows the frames that prove it. A run that fills +/// its box and holds together is one surface blinking (visibility/culling); many thin runs are +/// z-fighting resolving per fragment. Both read identically on the toggle percentage. +fn hotspot(dir: &Path, out: &Path, toggle_delta: u8, pad: u32) -> Result<()> { + let names: Vec = pngs(dir)?.into_iter().collect(); + let frames: Vec = names + .iter() + .map(|n| load(&dir.join(n))) + .collect::>()?; + let t = toggles(&frames, toggle_delta, TOGGLE_AMPLIFY)?; + let s = shape(&t); + let (w, h) = t.image.dimensions(); + println!( + "toggling {:.2}% coherence {:.3} {} runs >= {} px ({} px scattered)", + t.pct_toggling * 100.0, + s.coherence, + s.regions.len(), + benilla_visual::MIN_REGION_PIXELS, + s.scattered, + ); + for (i, r) in s.regions.iter().take(5).enumerate() { + println!( + " #{i} {:>8} px fill {:.2} box {}x{} at ({}, {})", + r.pixels, + r.fill(), + r.bounds.width(), + r.bounds.height(), + r.bounds.x0, + r.bounds.y0, + ); + // Pasteable straight into the in-game ray pick, which is the next question every time: + // WOW_PICK="" names the surfaces at these exact pixels, front to back. + let pick = r + .samples() + .iter() + .map(|(x, y)| format!("{x},{y}")) + .collect::>() + .join(";"); + println!(" WOW_PICK=\"{pick}\""); + } + let Some(biggest) = s.regions.first() else { + bail!( + "no toggling run of {} px or more — nothing to crop", + benilla_visual::MIN_REGION_PIXELS + ); + }; + // What the top runs are actually *doing*, frame by frame: re-shaded together, or edges moving. + // Side by side, because whether they flip on the SAME steps is the difference between one global + // cause and several local ones — and that is invisible in any single run's series. + let series: Vec> = s + .regions + .iter() + .take(SERIES_RUNS) + .map(|r| r.steps(&frames)) + .collect(); + println!(" step-by-step per run — mean luma, its delta, and how much of the run agreed:"); + for (i, _) in frames.windows(2).enumerate() { + let cols: Vec = series + .iter() + .map(|s| { + format!( + "{:6.2}{:+7.2} @{:.2}", + s[i].mean_from, s[i].mean_delta, s[i].agreement + ) + }) + .collect(); + println!(" {:>2} -> {:<2} {}", i, i + 1, cols.join(" ")); + } + // The two extremes of run #0, per channel. If a two-state flip is one light term switching, the + // channel ratios come out equal; if the surface swapped to a different (differently-coloured) + // lighting lane, they do not — and that is the whole difference between the two diagnoses. + let s0 = &series[0]; + let lo = s0 + .iter() + .min_by(|a, b| a.mean_from.total_cmp(&b.mean_from)) + .expect("a burst has at least one step"); + let hi = s0 + .iter() + .max_by(|a, b| a.mean_from.total_cmp(&b.mean_from)) + .expect("a burst has at least one step"); + let ratio: Vec = (0..3) + .map(|c| format!("{:.3}", hi.mean_rgb_from[c] / lo.mean_rgb_from[c].max(1e-9))) + .collect(); + println!( + " #0 extremes: dim rgb ({:.1}, {:.1}, {:.1}) bright rgb ({:.1}, {:.1}, {:.1}) per-channel ratio {}", + lo.mean_rgb_from[0], lo.mean_rgb_from[1], lo.mean_rgb_from[2], + hi.mean_rgb_from[0], hi.mean_rgb_from[1], hi.mean_rgb_from[2], + ratio.join(" / "), + ); + let rect = biggest.bounds.padded(pad, w, h); + // The toggle map goes in as the first tile, so the sheet carries its own legend: this is the + // region, and here is what the frames did inside it. + let mut tiles = vec![crop(&t.image, rect)]; + tiles.extend(frames.iter().map(|f| crop(f, rect))); + if let Some(parent) = out.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(parent).ok(); + } + contact_strip(&tiles, STRIP_COLS, COMPOSE_GAP) + .save(out) + .with_context(|| format!("writing hotspot strip {}", out.display()))?; + println!( + "crop {}x{} at ({}, {}) x {} tiles -> {}", + rect.width(), + rect.height(), + rect.x0, + rect.y0, + tiles.len(), + out.display() + ); + Ok(()) +} + +/// `foo.png` + `-toggle` → `foo-toggle.png`. The two maps answer the same question under opposite +/// camera conditions, so they are written as a pair rather than behind a second flag nobody would +/// remember to pass. +fn sibling(out: &Path, suffix: &str) -> PathBuf { + let ext = out.extension().and_then(|e| e.to_str()).unwrap_or("png"); + let stem = out.file_stem().and_then(|s| s.to_str()).unwrap_or("out"); + out.with_file_name(format!("{stem}{suffix}.{ext}")) +} + fn next(it: &mut std::slice::Iter, flag: &str) -> Result { it.next() .cloned() @@ -148,6 +413,13 @@ fn two<'a>(rest: &'a [String], cmd: &str) -> Result<[&'a str; 2]> { } } +fn one<'a>(rest: &'a [String], cmd: &str) -> Result<[&'a str; 1]> { + match rest { + [a] => Ok([a]), + _ => bail!("{cmd} needs exactly one path, got {}", rest.len()), + } +} + fn over_fail(m: &Metrics, fail: Option) -> bool { fail.is_some_and(|t| m.mae > t) } @@ -240,8 +512,14 @@ fn print_usage() { USAGE:\n \ benilla-visual diff [--out ] [--fail ] [--amplify ]\n \ benilla-visual diff-dir [--out ] [--fail ] [--amplify ]\n \ + benilla-visual flicker [--out ] [--fail ] [--amplify ] [--toggle-delta ]\n \ + benilla-visual hotspot --out [--toggle-delta ] [--pad ]\n \ benilla-visual compose-dir --out (side-by-side `a | b` per image)\n\ \n\ + flicker reads a WOW_LIVE_SHOT_COUNT burst (adjacent frames) in shot order and reports both\n\ + the parked reading (envelope) and the moving-camera one (toggles).\n\ + hotspot follows the toggle map: it measures the toggling pixels' shape — one solid run is a\n\ + surface blinking, many thin ones are z-fighting — and crops the largest into a contact sheet.\n\ Exits non-zero if any image's MAE exceeds --fail." ); } diff --git a/crates/benilla/assets/ui/ActionBar.xml b/crates/benilla/assets/ui/ActionBar.xml index ae6fd4d1..5d7ecf72 100644 --- a/crates/benilla/assets/ui/ActionBar.xml +++ b/crates/benilla/assets/ui/ActionBar.xml @@ -39,9 +39,12 @@ ref (which HIDES an empty, non-grid slot entirely), our bar always shows all 12 wells; the grid only swaps an EMPTY slot's ring art between the occupied and empty Quickslot textures — the default (nothing held) look is unchanged from before this slice, no capture regression. - - Exhaustion tick + rest fill, XP text (hover/cvar-gated), MaxLevelBar, the performance bar, - the four bag slots / keyring / micro buttons: OUT. The bag/keyring/micro cluster on the - bar's right is the visible remaining gap (only the backpack toggle is relocated there). + - Exhaustion tick + rest fill, XP text (hover/cvar-gated), MaxLevelBar: OUT. The bar's + right-hand cluster is otherwise complete — the backpack toggle + four bag slots live in + BagFrame.xml and the eight micro buttons in MicroMenu.xml (both anchored into + BenillaActionBarArtFrame and seated over the art by BenillaActionBarArt_SeatAbove below), + and the performance meter is at the foot of this file. Only the KEYRING is still out, with + the keyring container itself (the ref hides that button by default anyway). --> - + + @@ -587,6 +705,8 @@ BenillaExpBar_OnLoad(self) BenillaExpBar_OnEvent(self) + BenillaExpBar_OnEnter(self) + BenillaExpBar_OnLeave() @@ -723,6 +843,53 @@ + + + + + + + + + + + + + + + + + + + + BenillaPerformanceBar_OnLoad(self) + BenillaPerformanceBar_OnUpdate(self, arg1) + + BenillaActionBar_OnLoad(self) diff --git a/crates/benilla/assets/ui/BagFrame.xml b/crates/benilla/assets/ui/BagFrame.xml index b62f45bf..6c832866 100644 --- a/crates/benilla/assets/ui/BagFrame.xml +++ b/crates/benilla/assets/ui/BagFrame.xml @@ -568,20 +568,14 @@ -- DEFERRAL: no drag-to-equip a bag onto the bar yet (that's the paper-doll drag arc, slice -- 3's scope, not this one). -- The bag bar (and the backpack toggle) are RELOCATED onto the action bar — anchored into - -- BenillaActionBarArtFrame's bottom-right. But they are top-level frames, so they default to - -- a LOWER frame level than the bar's own art (which is a child hierarchy: the ExpBar's dwarf - -- notches + the metal/well art sit at the bar's child level). Frame level dominates the flat - -- painter's order, so the bar art would paint OVER these buttons' icons — the icon draws, the - -- ring shows around it, but the centered icon is fully covered (the "empty rings, no bag - -- icons" bug). The action bar's OWN 12 buttons avoid this because they're CHILDREN of the art - -- frame (child-over-parent). Seat the bag-bar frames one level above the art — the same level - -- the action buttons get — so their icons draw above the bar art. (SetParent onto the art - -- frame would be the structural twin, but the engine keys draw order at creation and does not - -- re-key on a runtime reparent, so the explicit level is the reliable lever.) - function BenillaBagBar_SeatAboveActionBar(frame) - local art = getglobal("BenillaActionBarArtFrame") - if art then frame:SetFrameLevel(art:GetFrameLevel() + 1) end - end + -- BenillaActionBarArtFrame's bottom-right — which means they need seating one frame level + -- above the bar art or it paints over their icons. That lever lives with the frame it's + -- about: BenillaActionBarArt_SeatAbove (ActionBar.xml, loaded before this file), which + -- carries the full explanation; the micro-button row is its second caller. Called + -- nil-GUARDED here: the shipped load order always has the bar first, but ~25 bag/bank/ + -- escape/merchant test harnesses load THIS file alone — and with no bar loaded there is by + -- definition no bar art to seat above, so the guard's no-op is the right answer, not a + -- swallowed failure. -- Bag0Slot..Bag3Slot = live inv ids 20..23 (char_stats.rs SLOT_INFO, DBC-verified); OUR bag -- ids 1..4 map the same way (bagId N ⇒ inv slot 19+N), the id PickupInventoryItem/ @@ -591,7 +585,7 @@ function BenillaBagBarSlot_OnLoad(button, bagId) button.bagId = bagId button.icon = getglobal(button:GetName() .. "Icon") - BenillaBagBar_SeatAboveActionBar(button) + if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(button) end button:RegisterForClicks("LeftButtonUp") -- Drag-to-equip (decision 0208 phase 1b's deferred bag-icon arm, now landed): the bar -- slot is a paper-doll equipment slot for bags. LEFT drag picks the equipped bag up; @@ -642,6 +636,49 @@ BenillaBagBarSlot_Update(button) end + -- ── The bar buttons' hovers ─────────────────────────────────────────────────────────── + -- ref GlobalStrings.lua l.132 / l.1458 (enUS), fallback form: the real localized + -- GlobalStrings.lua runs before this file at runtime (ui_script/mod.rs), so these only + -- fill in for the harnesses that load no patch chain. + BACKPACK_TOOLTIP = BACKPACK_TOOLTIP or "Backpack" + EQUIP_CONTAINER = EQUIP_CONTAINER or "Equip Container" + + -- Both are PLAIN tooltips — the reference calls GameTooltip:SetText here, not + -- GameTooltip_AddNewbieTip, so unlike the micro buttons next door (0661) there is no second + -- explanation line to be missing. They also anchor beside the button (ANCHOR_LEFT, so the + -- plate opens toward screen center off a bottom-right button), NOT at the default corner. + -- + -- ref MainMenuBarBackpackButton OnEnter (ref-MainMenuBarBagButtons.xml l.91-99). The ref + -- appends the live TOGGLEBACKPACK key with GameTooltip:AppendText; this engine has neither + -- GetBindingKey nor AppendText, so the suffix is concatenated from the one bare-key binding + -- we actually ship (ui_script/input.rs `bare_key_binding`: KeyB) — MicroMenu.xml's own shape + -- for the same problem, and the rendered line is identical. + function BenillaBagToggle_OnEnter(button) + if not GameTooltip then return end -- engine tests load file subsets + GameTooltip:SetOwner(button, "ANCHOR_LEFT") + GameTooltip:SetText( + BACKPACK_TOOLTIP .. " " .. NORMAL_FONT_COLOR_CODE .. "(B)" .. FONT_COLOR_CODE_CLOSE, + 1.0, 1.0, 1.0) + end + + -- ref BagSlotButton_OnEnter (ref-MainMenuBarBagButtons.lua l.86-96): the equipped bag's own + -- item tooltip, or "Equip Container" for an empty slot. The ref's "(F1)".."(F4)" suffix + -- needs GetBindingKey AND the TOGGLEBAG1..4 bindings — benilla ships neither, so no slot + -- carries one; the day those bind, this is where the suffix goes. + function BenillaBagBarSlot_OnEnter(button) + if not GameTooltip then return end + GameTooltip:SetOwner(button, "ANCHOR_LEFT") + if not GameTooltip:SetInventoryItem("player", BenillaBagBarSlot_InvSlot(button)) then + GameTooltip:SetText(EQUIP_CONTAINER, 1.0, 1.0, 1.0) + end + end + + -- ref OnLeave for both (l.100-102 / the template's own): a plain Hide, no fade — the item + -- half of the split BenillaUnitFrame_OnLeave's comment describes. + function BenillaBagBarButton_OnLeave() + if GameTooltip then GameTooltip:Hide() end + end + function BenillaBagBarSlot_Update(button) local invSlot = BenillaBagBarSlot_InvSlot(button) local texture = GetInventoryItemTexture("player", invSlot) @@ -1164,9 +1201,11 @@ - BenillaBagBar_SeatAboveActionBar(self) + BenillaActionBarArt_SeatAbove's comment). --> + if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(self) end BenillaBagToggle_OnClick() + BenillaBagToggle_OnEnter(self) + BenillaBagBarButton_OnLeave() @@ -1196,6 +1235,8 @@ BenillaBagBarSlot_OnLoad(self, 1) BenillaBagBarSlot_OnClick(self) BenillaBagBarSlot_OnEvent(self) + BenillaBagBarSlot_OnEnter(self) + BenillaBagBarButton_OnLeave() @@ -1219,6 +1260,8 @@ BenillaBagBarSlot_OnLoad(self, 2) BenillaBagBarSlot_OnClick(self) BenillaBagBarSlot_OnEvent(self) + BenillaBagBarSlot_OnEnter(self) + BenillaBagBarButton_OnLeave() @@ -1242,6 +1285,8 @@ BenillaBagBarSlot_OnLoad(self, 3) BenillaBagBarSlot_OnClick(self) BenillaBagBarSlot_OnEvent(self) + BenillaBagBarSlot_OnEnter(self) + BenillaBagBarButton_OnLeave() @@ -1265,6 +1310,8 @@ BenillaBagBarSlot_OnLoad(self, 4) BenillaBagBarSlot_OnClick(self) BenillaBagBarSlot_OnEvent(self) + BenillaBagBarSlot_OnEnter(self) + BenillaBagBarButton_OnLeave() diff --git a/crates/benilla/assets/ui/CharacterFrame.xml b/crates/benilla/assets/ui/CharacterFrame.xml index 52b4a9bc..ba820389 100644 --- a/crates/benilla/assets/ui/CharacterFrame.xml +++ b/crates/benilla/assets/ui/CharacterFrame.xml @@ -99,16 +99,17 @@ 970-978`): no Lua in this session's extraction ever populates it (likely an ammo-picker- addon-only overlay outside this pull) — dropped rather than guessed at. - Engine-gap notes (transcribed anyway per this file's own instructions, or worked around): + Engine-gap notes (transcribed anyway per this file's own instructions, or worked around) — + the first two are CLOSED, kept only because the workarounds below still read as if they held: - `HitRectInsets` (both `BenillaCharacterFrame` and `BenillaPaperDollFrame`, ref's identical - 0/30/0/45 on both) has no loader wiring yet (grepped `benilla-ui/src/loader` — no - `HitRectInsets` handling exists) — included anyway (harmless no-op, documents the ref's - intent for whenever hit-rect insets land). - - `enableMouse`/`setAllPoints` (frame-level) are also no-ops on `` (only the - region-level `setAllPoints` shorthand is wired, `loader/regions.rs:130`) — ` - BenillaPaperDollFrame` therefore gets an explicit `Size`+`TOPLEFT` anchor instead of the - ref's `setAllPoints="true"` (the same effect, since it's sized to exactly fill its - parent). + 0/30/0/45 on both) was a loader no-op when this file was written; it landed with the + micro menu (decision 0655) and now really insets the mouse rect. + - `enableMouse`/`setAllPoints` (frame-level) were no-ops on `` too; both are wired + now (`loader/geometry.rs`'s `apply_attrs`/`apply_anchors`, and + `loader/tests.rs::frame_set_all_points_pins_to_parent`). `BenillaPaperDollFrame` still + carries an explicit `Size`+`TOPLEFT` anchor rather than the ref's `setAllPoints="true"` + — exactly equivalent for a child sized to fill its parent, so it is left alone rather + than churned. - A plain `` isn't mouse-enabled by construction in this engine (only Button/ CheckButton/EditBox/ScrollingMessageFrame are — `widget/mod.rs`'s ctor comment, already the reason `UnitFrames.xml` calls `EnableMouse(true)` on its own root frame) — every @@ -291,18 +292,21 @@ PanelTemplates_SetTab(frame, 1) end - -- ref CharacterFrame_OnShow (l.82-93): UpdateMicroButtons/Show*TextStatusBarText/ - -- ShowWatchedReputationBarText all OMITTED — no micro-button bar, unit-frame bar-text-mode - -- toggle, or reputation-bar-text mechanism exists in this engine. UnitPVPName -> plain - -- UnitName (this file's header comment: PVP rank is a named deferral). + -- ref CharacterFrame_OnShow (l.82-93): Show*TextStatusBarText/ShowWatchedReputationBarText + -- OMITTED — no unit-frame bar-text-mode toggle or reputation-bar-text mechanism exists in + -- this engine. UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml); it was + -- omitted only for want of a micro bar. UnitPVPName -> plain UnitName (this file's header + -- comment: PVP rank is a named deferral). function BenillaCharacterFrame_OnShow() + if UpdateMicroButtons then UpdateMicroButtons() end PlaySound("igCharacterInfoOpen") SetPortraitTexture(getglobal("BenillaCharacterFramePortrait"), "player") getglobal("BenillaCharacterNameText"):SetText(UnitName("player")) end -- ref CharacterFrame_OnHide (l.95-104): everything past the PlaySound is the OnShow list's - -- Hide-twin, OMITTED for the same reason. The subframe hide-loop is OUR OWN addition + -- Hide-twin — UpdateMicroButtons live (MicroMenu.xml), the bar-text pair still OMITTED for + -- the same reason as above. The subframe hide-loop is OUR OWN addition -- (decision 0437 phase 4): BenillaPaperDollFrame is a true engine child of this frame (its -- own header comment) so it hides for free on the parent's Hide-cascade, but -- BenillaSkillFrame is a SEPARATE top-level file — this engine has no cross-file `parent=` @@ -313,6 +317,7 @@ -- UIPanel exclusivity). Looping every BENILLA_CHARACTERFRAME_SUBFRAMES entry here, once, -- keeps this correct as more non-nested tabs (Pet/Reputation/Honor) land the same way. function BenillaCharacterFrame_OnHide() + if UpdateMicroButtons then UpdateMicroButtons() end PlaySound("igCharacterInfoClose") for _, name in ipairs(BENILLA_CHARACTERFRAME_SUBFRAMES) do local subFrame = getglobal(name) @@ -1282,9 +1287,10 @@ BenillaCharacterFrameTab_OnClick(2) - + diff --git a/crates/benilla/assets/ui/ChatFrame.xml b/crates/benilla/assets/ui/ChatFrame.xml index 405ec980..2e21ba97 100644 --- a/crates/benilla/assets/ui/ChatFrame.xml +++ b/crates/benilla/assets/ui/ChatFrame.xml @@ -210,6 +210,170 @@ end end end + + -- ── BenillaUIMenu: the 1.12 UIMenu kit, transcribed (ref UIMenu.lua — the chat menu's + -- row engine: fixed 16px rows in a tooltip-backdrop box, a 2 s no-hover timeout that + -- walks up parentMenu chains, nested submenus opened on row hover). Constants quoted. + -- Scoped here because the chat menu is its only consumer; a second consumer graduates + -- it to its own file. + BENILLA_UIMENU_BUTTON_HEIGHT = 16 + BENILLA_UIMENU_BUTTON_WIDTH = 104 + BENILLA_UIMENU_BORDER_HEIGHT = 12 + BENILLA_UIMENU_BORDER_WIDTH = 12 + BENILLA_UIMENU_NUMBUTTONS = 32 + BENILLA_UIMENU_TIMEOUT = 2.0 + + function BenillaUIMenu_Initialize(menu) + menu.numButtons = 0 + menu.subMenu = "" + local name = menu:GetName() + for i = 1, BENILLA_UIMENU_NUMBUTTONS do + local button = getglobal(name .. "Button" .. i) + button:SetWidth(BENILLA_UIMENU_BUTTON_WIDTH) + button:SetHeight(BENILLA_UIMENU_BUTTON_HEIGHT) + button:Hide() + getglobal(button:GetName() .. "ShortcutText"):Hide() + end + menu:SetWidth(BENILLA_UIMENU_BUTTON_WIDTH + BENILLA_UIMENU_BORDER_WIDTH * 2) + menu:SetHeight(BENILLA_UIMENU_BORDER_HEIGHT * 2) + end + + -- Rows past the button pool are dropped (the ref message()s; nothing we author overflows). + function BenillaUIMenu_AddButton(menu, text, shortcut, func, nested) + local id = menu.numButtons + 1 + if id > BENILLA_UIMENU_NUMBUTTONS then return end + menu.numButtons = id + local button = getglobal(menu:GetName() .. "Button" .. id) + if text then button:SetText(text) end + button.func = func + button.nested = nested + button:Show() + if shortcut then + local s = getglobal(button:GetName() .. "ShortcutText") + s:SetText(shortcut) + s:Show() + end + menu:SetHeight(id * BENILLA_UIMENU_BUTTON_HEIGHT + BENILLA_UIMENU_BORDER_HEIGHT * 2) + end + + function BenillaUIMenu_OnShow() + this.timeleft = BENILLA_UIMENU_TIMEOUT + this.counting = 1 + end + + function BenillaUIMenu_OnUpdate(elapsed) + if this.counting == 1 then + local timeleft = (this.timeleft or BENILLA_UIMENU_TIMEOUT) - elapsed + if timeleft <= 0 then + this:Hide() + return + end + this.timeleft = timeleft + end + end + + function BenillaUIMenu_StartCounting(menu) + menu.counting = 1 + if menu.parentMenu then BenillaUIMenu_StartCounting(getglobal(menu.parentMenu)) end + end + + function BenillaUIMenu_StopCounting(menu) + menu.counting = 0 + menu.timeleft = BENILLA_UIMENU_TIMEOUT + if menu.parentMenu then BenillaUIMenu_StopCounting(getglobal(menu.parentMenu)) end + end + + function BenillaUIMenuButton_OnLoad() + this:SetPoint("TOP", this:GetParent():GetName(), "TOP", 0, + -((this:GetID() - 1) * BENILLA_UIMENU_BUTTON_HEIGHT) - BENILLA_UIMENU_BORDER_HEIGHT) + end + + function BenillaUIMenuButton_OnClick() + if this.func then this.func() end + this:GetParent():Hide() + PlaySound("UChatScrollButton") + end + + function BenillaUIMenuButton_OnEnter() + local nested = this.nested + if nested then + local menu = getglobal(nested) + if not menu:IsVisible() then + local oldMenu = getglobal(this:GetParent().subMenu or "") + if oldMenu then oldMenu:Hide() end + this:GetParent().subMenu = nested + menu:SetPoint("BOTTOMLEFT", this:GetName(), "BOTTOMRIGHT", 10, -12) + menu:Show() + end + BenillaUIMenu_StopCounting(menu) + else + BenillaUIMenu_StopCounting(this:GetParent()) + end + end + + function BenillaUIMenuButton_OnLeave() + BenillaUIMenu_StartCounting(this:GetParent()) + if this.nested then BenillaUIMenu_StartCounting(getglobal(this.nested)) end + end + + -- ── The chat menu rows (ref ChatMenu_* in ChatFrame.lua, adapted): every type row + -- prefixes the box with the type's slash and lets the LIVE PARSE convert — the ref's + -- own Whisper row does literally `SetText("/w "..GetText())`, and routing all rows the + -- same way means the menu can never drift from the slash grammar the edit machine + -- owns. Labels/shortcuts quoted from GlobalStrings. Language (benilla has no language + -- model yet) and Macro (no macro frame) are deliberately out; this OnLoad is their + -- return path. + function BenillaChatMenu_Type(prefix) + local eb = ChatFrameEditBox + eb:Show() + eb:SetFocus() + eb:SetText(prefix .. (eb:GetText() or "")) + end + + function BenillaChatMenu_OnLoad() + BenillaUIMenu_Initialize(this) + BenillaUIMenu_AddButton(this, "Say", "/s", function() BenillaChatMenu_Type("/s ") end) + BenillaUIMenu_AddButton(this, "Party Chat", "/p", function() BenillaChatMenu_Type("/p ") end) + BenillaUIMenu_AddButton(this, "Guild Chat", "/g", function() BenillaChatMenu_Type("/g ") end) + BenillaUIMenu_AddButton(this, "Yell", "/y", function() BenillaChatMenu_Type("/y ") end) + BenillaUIMenu_AddButton(this, "Whisper", "/w", function() BenillaChatMenu_Type("/w ") end) + BenillaUIMenu_AddButton(this, "Emote", "/e", function() BenillaChatMenu_Type("/e ") end, "EmoteMenu") + BenillaUIMenu_AddButton(this, "Reply", "/r", function() BenillaChatMenu_Type("/r ") end) + BenillaUIMenu_AddButton(this, "Voice Emote", nil, nil, "VoiceMacroMenu") + end + + -- An emote row submits its /command through the REAL chat pipeline (SubmitChatInput → + -- the router's EmotesText resolve + the send-side posture gate) — the ref's DoEmote by + -- token, reached through our one grammar. The lists are the ref's EmoteList / + -- TextEmoteSpeechList tokens as OUR canonical /commands (the EmotesText names the typed + -- grammar resolves), CMD1-sorted like the ref menu. Three ref labels differ + -- (/attacktarget /followme /silly are GlobalStrings CMD aliases the typed grammar + -- doesn't carry yet) — each row shows the command it actually runs. + BenillaEmoteMenuList = { + "/applaud", "/beg", "/bow", "/chicken", "/cry", "/dance", "/eat", "/flex", + "/kiss", "/kneel", "/laugh", "/point", "/roar", "/rude", "/salute", "/shy", + "/sit", "/sleep", "/stand", "/talk", "/wave", + } + BenillaVoiceMacroList = { + "/attackmytarget", "/bye", "/charge", "/cheer", "/congratulate", "/flee", + "/flirt", "/follow", "/healme", "/hello", "/helpme", "/incoming", "/joke", + "/no", "/nod", "/oom", "/openfire", "/rasp", "/thank", "/train", "/wait", + "/welcome", + } + + function BenillaEmoteMenu_Click(cmd) + SubmitChatInput(cmd) + ChatMenu:Hide() + end + + function BenillaOnEmoteMenuLoad(list) + BenillaUIMenu_Initialize(this) + this.parentMenu = "ChatMenu" + for _, cmd in ipairs(list) do + local c = cmd + BenillaUIMenu_AddButton(this, cmd, nil, function() BenillaEmoteMenu_Click(c) end) + end + end ]]> @@ -466,7 +630,14 @@ + + @@ -474,6 +645,152 @@ + + + + + + + + + + + + + + + + + + + + + BenillaOnEmoteMenuLoad(BenillaEmoteMenuList) + + + + + + + BenillaOnEmoteMenuLoad(BenillaVoiceMacroList) + + + + + + + BenillaChatMenu_OnLoad() + + + BenillaUIMenu_OnShow() + EmoteMenu:Hide() + VoiceMacroMenu:Hide() + this:SetBackdropBorderColor(TOOLTIP_DEFAULT_COLOR.r, TOOLTIP_DEFAULT_COLOR.g, TOOLTIP_DEFAULT_COLOR.b) + this:SetBackdropColor(TOOLTIP_DEFAULT_BACKGROUND_COLOR.r, TOOLTIP_DEFAULT_BACKGROUND_COLOR.g, TOOLTIP_DEFAULT_BACKGROUND_COLOR.b) + + + + - diff --git a/crates/benilla/assets/ui/SpellBookFrame.xml b/crates/benilla/assets/ui/SpellBookFrame.xml index c99d0e52..2881804a 100644 --- a/crates/benilla/assets/ui/SpellBookFrame.xml +++ b/crates/benilla/assets/ui/SpellBookFrame.xml @@ -61,10 +61,12 @@ - `maxLines="3"` on `$parentSpellName` (word-wrap line cap): no such FontString attribute is modeled (grepped `benilla-ui/src/loader/widgets.rs`) — the name just wraps unbounded inside its box width, a harmless cosmetic gap on the rare long name. - - `UpdateMicroButtons()`/`MultiActionBar_ShowAllGrids/_HideAllGrids()` (ref - `SpellBookFrame_OnShow`/`_OnHide`): no micro-button bar or multi-bar grid-show - mechanism exists in this engine yet — dropped, the same class of gap CharacterFrame's - own OnShow/OnHide already carry. + - `MultiActionBar_ShowAllGrids/_HideAllGrids()` (ref `SpellBookFrame_OnShow`/`_OnHide`): + no multi-bar grid-show mechanism exists in this engine yet — dropped, the same class of + gap CharacterFrame's own OnShow/OnHide already carry. Its neighbour in that pair, + `UpdateMicroButtons()`, is LIVE since the micro-menu slice (`MicroMenu.xml`) — nil- + guarded at the call sites only because several test harnesses load this window without + the action bar behind it. Sounds (decision 0090): `igSpellBookOpen`/`igSpellBookClose` on show/hide (ref `SpellBookFrame_Update`/`_OnHide`, VERIFIED present in the real `SoundEntries.dbc` this @@ -137,9 +139,11 @@ end end - -- ref SpellBookFrame_OnShow (l.94-105): UpdateMicroButtons/MultiActionBar_ShowAllGrids - -- OMITTED (header comment); the tab-flash UIFrameFlash call goes with LEARNED_SPELL_IN_TAB. + -- ref SpellBookFrame_OnShow (l.94-105): MultiActionBar_ShowAllGrids OMITTED (header + -- comment); the tab-flash UIFrameFlash call goes with LEARNED_SPELL_IN_TAB. + -- UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml). function BenillaSpellBookFrame_OnShow() + if UpdateMicroButtons then UpdateMicroButtons() end BenillaSpellBookFrame_Update(1) end @@ -170,10 +174,11 @@ end end - -- ref SpellBookFrame_OnHide (l.186-204): UpdateMicroButtons/MultiActionBar_HideAllGrids - -- and the tab-flash cleanup OMITTED (header comment); always the spell-book close kit - -- (no pet book to branch on). + -- ref SpellBookFrame_OnHide (l.186-204): MultiActionBar_HideAllGrids and the tab-flash + -- cleanup OMITTED (header comment); always the spell-book close kit (no pet book to branch + -- on). UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml). function BenillaSpellBookFrame_OnHide() + if UpdateMicroButtons then UpdateMicroButtons() end PlaySound("igSpellBookClose") end diff --git a/crates/benilla/assets/ui/TalentFrame.xml b/crates/benilla/assets/ui/TalentFrame.xml index e934bd0c..8dbfb254 100644 --- a/crates/benilla/assets/ui/TalentFrame.xml +++ b/crates/benilla/assets/ui/TalentFrame.xml @@ -89,11 +89,13 @@ are reused as-is: both read only a Slider's own value/range/step, nothing faux-specific. Named deferrals/OMITs (each re-cited at its own site below): - - `SetButtonPulse`/`UpdateMicroButtons` (ref `TalentFrame_OnShow`/`_OnHide`/`TalentFrameTab_ - OnClick`'s pulse-clear loop, and `TalentTabTemplate`'s own `PLAYER_LEVEL_UP` level-9 - flash-hint OnLoad/OnEvent): decision 0304's own Scope-calls section, "No microbutton - pulse — the reference pulses TalentMicroButton on point gain; benilla has no microbar - yet." All four sites drop cleanly (none gate anything else). + - `SetButtonPulse` (ref `TalentFrame_OnShow`/`_OnHide`/`TalentFrameTab_OnClick`'s + pulse-clear loop, and `TalentTabTemplate`'s own `PLAYER_LEVEL_UP` level-9 flash-hint + OnLoad/OnEvent): decision 0304's own Scope-calls section, "No microbutton pulse — the + reference pulses TalentMicroButton on point gain". The microbar itself has since landed + (`MicroMenu.xml`) and `UpdateMicroButtons()` is live in OnShow/OnHide below — but the + PULSE stays out: it rides `PLAYER_LEVEL_UP`, which this client never fires. All four + pulse sites drop cleanly (none gate anything else). - `message(...)` (5 sites: the `numTalents > MAX_NUM_TALENTS` reminder, `_GetArrowTexture`/ `_GetBranchTexture`'s pool-exhaustion notices, and 3 sites in `_DrawLines`'s own layout- blocked guards): no engine `message()` global exists (grepped `crates/benilla-ui/src`; @@ -281,15 +283,18 @@ end end - -- ref TalentFrame_OnShow (l.95-103): SetButtonPulse/UpdateMicroButtons OMITTED — this - -- file's header comment (decision 0304's own "No microbutton pulse" scope call). + -- ref TalentFrame_OnShow (l.95-103): SetButtonPulse still OMITTED (decision 0304's own "No + -- microbutton pulse" scope call — the pulse rides PLAYER_LEVEL_UP, which this client never + -- fires). UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml). function BenillaTalentFrame_OnShow() + if UpdateMicroButtons then UpdateMicroButtons() end PlaySound("TalentScreenOpen") BenillaTalentFrame_Update() end - -- ref TalentFrame_OnHide (l.105-108): UpdateMicroButtons OMITTED, same citation. + -- ref TalentFrame_OnHide (l.105-108). function BenillaTalentFrame_OnHide() + if UpdateMicroButtons then UpdateMicroButtons() end PlaySound("TalentScreenClose") end @@ -1126,8 +1131,8 @@