diff --git a/crates/benilla-formats/examples/wmo_dark_floors.rs b/crates/benilla-formats/examples/wmo_dark_floors.rs new file mode 100644 index 00000000..2c227927 --- /dev/null +++ b/crates/benilla-formats/examples/wmo_dark_floors.rs @@ -0,0 +1,220 @@ +//! **Every WMO in the chain that authors a surface too dark to render on its own** — the world sweep +//! behind decision 0956's Dire Maul report: +//! `cargo run --release -p benilla-formats --example wmo_dark_floors [-- ]` +//! +//! The bug 0956 fixed was never Dire Maul's: a WMO transition group may bake a surface at near-black +//! with `MOCV.α = 0`, leaving the runtime bright-doorway fade (`FixColorVertexAlpha`) to light it. +//! Skip the fade and the interior TRANS/INT law renders the bake literally — a black floor. One +//! building was *reported*; this asks the question the report could not, which is how many others +//! were sitting there unreported. +//! +//! Severity is **area, not vertex count**. A vertex is *dark* when the file bakes it near-black at +//! zero alpha (luminance < 48, `α == 0`) and the fade lifts it past 200 — it would have drawn black +//! and now draws lit. A **triangle** counts when all three of its vertices are, and its model-space +//! area is summed: that is literally the square yardage of black floor a player would have walked +//! over (floor, wall or ceiling alike). Batches are reported with their share of their own surface, so a wholly-black walkway +//! (Dire Maul: 100%) sorts above a darkened seam. +//! +//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. + +use benilla_wmo::{parse_wmo, ParsedWmo}; + +/// Rec. 601 luminance of a BGRA slot, in bytes. +fn lum(c: [u8; 4]) -> f32 { + 0.299 * f32::from(c[2]) + 0.587 * f32::from(c[1]) + 0.114 * f32::from(c[0]) +} + +/// Would this slot have rendered black, and does the fade rescue it? +fn dark(raw: [u8; 4], fixed: [u8; 4]) -> bool { + raw[3] == 0 && lum(raw) < 48.0 && lum(fixed) > 200.0 +} + +/// One reported surface: a batch of a group whose bake would not have rendered. +struct Hit { + wmo: String, + group: usize, + batch: usize, + class: &'static str, + texture: String, + /// Square yards of triangle whose every vertex was dark. + area: f32, + /// That area as a share of the batch's own total. + share: f32, +} + +fn main() -> anyhow::Result<()> { + let filter = std::env::args().nth(1).map(|s| s.to_lowercase()); + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + let mut chain = benilla_formats::open_chain(&data)?; + + // Root .wmo files only: a group file is `_NNN.wmo`. + let mut roots: Vec = chain + .list()? + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_lowercase(); + l.ends_with(".wmo") + && !l[..l.len() - 4] + .rsplit_once('_') + .is_some_and(|(_, t)| t.len() == 3 && t.bytes().all(|b| b.is_ascii_digit())) + }) + .filter(|n| filter.as_ref().is_none_or(|f| n.to_lowercase().contains(f))) + .collect(); + roots.sort(); + roots.dedup(); + eprintln!("sweeping {} WMO roots…", roots.len()); + + let mut hits: Vec = Vec::new(); + let (mut scanned, mut groups) = (0usize, 0usize); + + for path in &roots { + let Ok(root_bytes) = chain.read_file(&path.to_ascii_lowercase()) else { + continue; + }; + let Ok(root) = benilla_formats::parse_wmo_root(&root_bytes) else { + continue; + }; + let Ok(ParsedWmo::Root(raw_root)) = parse_wmo(&mut std::io::Cursor::new(&root_bytes)) + else { + continue; + }; + scanned += 1; + let stem = &path[..path.len() - 4]; + for gi in 0..root.group_count() as usize { + let Ok(gbytes) = chain.read_file(&format!("{stem}_{gi:03}.wmo").to_ascii_lowercase()) + else { + continue; + }; + let (Some(raw), Some(fixed)) = ( + benilla_formats::wmo_group_raw_colors(&gbytes), + benilla_formats::wmo_group_fixed_colors(&gbytes, &root), + ) else { + continue; + }; + let Ok(ParsedWmo::Group(group)) = parse_wmo(&mut std::io::Cursor::new(&gbytes)) else { + continue; + }; + groups += 1; + let is_dark: Vec = raw.iter().zip(&fixed).map(|(r, f)| dark(*r, *f)).collect(); + if !is_dark.iter().any(|&d| d) { + continue; + } + // MOBA is laid out TRANS, INT, EXT — only the first two render the bake. + let (trans_n, int_n) = benilla_formats::wmo_group_header(&gbytes) + .map(|_| ()) + .and_then(|()| { + let m = mogp(&gbytes)?; + (m.len() >= 0x2c).then(|| { + ( + u16::from_le_bytes([m[0x28], m[0x29]]) as usize, + u16::from_le_bytes([m[0x2a], m[0x2b]]) as usize, + ) + }) + }) + .unwrap_or((0, 0)); + for (bi, batch) in group.render_batches.iter().enumerate() { + let class = if bi < trans_n { + "TRANS" + } else if bi < trans_n + int_n { + "INT" + } else { + continue; // EXT batches take the exterior law; the bake never shows + }; + let start = batch.start_index as usize; + let idx = group + .vertex_indices + .get(start..start + batch.count as usize) + .unwrap_or(&[]); + let (mut dark_area, mut total_area) = (0.0f32, 0.0f32); + for t in idx.chunks_exact(3) { + let p: Vec<[f32; 3]> = t + .iter() + .filter_map(|&i| group.vertex_positions.get(i as usize)) + .map(|v| [v.x, v.y, v.z]) + .collect(); + if p.len() != 3 { + continue; + } + let (u, v) = ( + [p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]], + [p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]], + ); + let c = [ + 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 a = 0.5 * (c[0] * c[0] + c[1] * c[1] + c[2] * c[2]).sqrt(); + total_area += a; + if t.iter() + .all(|&i| is_dark.get(i as usize).copied() == Some(true)) + { + dark_area += a; + } + } + // A quad of black floor is ~10 yd²; below 2 yd² is a seam, not a surface. + if dark_area < 2.0 { + continue; + } + let material = raw_root.materials.get(batch.material_id as usize); + let texture = material + .map(|m| m.get_texture1_index(&raw_root.texture_offset_index_map)) + .and_then(|i| raw_root.textures.get(i as usize)) + .map(|t| t.rsplit('\\').next().unwrap_or(t).to_string()) + .unwrap_or_else(|| "(none)".into()); + hits.push(Hit { + wmo: path.clone(), + group: gi, + batch: bi, + class, + texture, + area: dark_area, + share: dark_area / total_area.max(1e-6), + }); + } + } + } + + hits.sort_by(|a, b| b.area.total_cmp(&a.area)); + println!("\n=== Surfaces that rendered black before the doorway fade (by area) ===\n"); + for h in &hits { + println!( + "{:9.1} yd² {:3.0}% g{:03} b{:<3} {:<5} {:<28} {}", + h.area, + h.share * 100.0, + h.group, + h.batch, + h.class, + h.texture, + h.wmo + ); + } + let buildings: std::collections::BTreeSet<&str> = hits.iter().map(|h| h.wmo.as_str()).collect(); + println!( + "\n{} surface(s) across {} building(s) of {scanned} scanned ({groups} MOCV groups); \ + {:.0} yd² of unrenderable surface in total", + hits.len(), + buildings.len(), + hits.iter().map(|h| h.area).sum::() + ); + Ok(()) +} + +/// The MOGP super-chunk payload of a group file. +fn mogp(bytes: &[u8]) -> Option<&[u8]> { + let mut off = 0usize; + while off + 8 <= bytes.len() { + let len = u32::from_le_bytes([ + bytes[off + 4], + bytes[off + 5], + bytes[off + 6], + bytes[off + 7], + ]) as usize; + if &bytes[off..off + 4] == b"PGOM" { + return bytes.get(off + 8..(off + 8 + len).min(bytes.len())); + } + off += 8 + len; + } + None +} diff --git a/crates/benilla-formats/examples/wmo_mocv.rs b/crates/benilla-formats/examples/wmo_mocv.rs new file mode 100644 index 00000000..df2567b5 --- /dev/null +++ b/crates/benilla-formats/examples/wmo_mocv.rs @@ -0,0 +1,305 @@ +//! What a WMO's surfaces are **baked** to — the per-group MOCV census behind a "this floor is black" +//! report: +//! `cargo run -p benilla-formats --example wmo_mocv -- [gN | x,y,z]` +//! +//! An interior WMO surface takes no exterior light in the reference: an INT-class batch draws +//! `tex × MOCV` and a TRANS-class batch lerps to it by `MOCV.a`, so the *only* thing between a black +//! floor and a lit one is the bake in the file — plus the runtime portal fixup the client applies +//! over it (`FixColorVertexAlpha`, wow-re `wmo-group-lighting.md §4`, which brightens interior verts +//! toward white within 6.67 yd of an exterior-neighbour portal). A "dark floor here" report therefore +//! has exactly two shapes, told apart by numbers rather than by looking: either the file bakes it +//! dark (and the fixup is what lights it), or our reader/classifier mislabels the batch. This prints +//! both halves — the group's MOGP flags and per-batch class, and the MOCV spread of each batch's +//! vertices split by facing, so a FLOOR (n.z up) reads apart from the walls sharing its batch. +//! +//! With a model-space `x,y,z` it reports only the groups whose bounding box contains the point (a +//! `.gps` pin, inverse-transformed by the placement, names the group the director is standing in). +//! +//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. + +use benilla_wmo::{parse_wmo, ParsedWmo}; + +/// Walk a WMO file's top-level chunks for `magic` (FourCC reversed on disk), returning its payload. +/// The examples can't use the crate-private reader, and a group file's MOGP header is the only thing +/// here that isn't already on a parsed struct. +fn chunk<'a>(bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a [u8]> { + let mut off = 0usize; + while off + 8 <= bytes.len() { + let id = &bytes[off..off + 4]; + let len = u32::from_le_bytes([ + bytes[off + 4], + bytes[off + 5], + bytes[off + 6], + bytes[off + 7], + ]) as usize; + let body = off + 8; + if id == magic { + return bytes.get(body..(body + len).min(bytes.len())); + } + off = body + len; + } + None +} + +/// The MOCV spread of one set of vertices, in bytes. +struct Stat { + n: usize, + lum_min: f32, + lum_max: f32, + sum: [f32; 4], +} + +impl Stat { + fn new() -> Self { + Self { + n: 0, + lum_min: f32::MAX, + lum_max: f32::MIN, + sum: [0.0; 4], + } + } + fn add(&mut self, c: [f32; 4]) { + let lum = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; + self.n += 1; + self.lum_min = self.lum_min.min(lum); + self.lum_max = self.lum_max.max(lum); + for (i, ch) in c.iter().enumerate() { + self.sum[i] += ch; + } + } + fn line(&self) -> String { + if self.n == 0 { + return "—".into(); + } + let k = self.n as f32; + format!( + "n={:<5} mean rgba ({:3.0},{:3.0},{:3.0},{:3.0}) lum {:3.0}..{:3.0}", + self.n, + self.sum[0] / k, + self.sum[1] / k, + self.sum[2] / k, + self.sum[3] / k, + self.lum_min, + self.lum_max, + ) + } +} + +fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let pat = args + .next() + .ok_or_else(|| anyhow::anyhow!("usage: wmo_mocv [gN | x,y,z]"))?; + let filter = args.next(); + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + let mut chain = benilla_formats::open_chain(&data)?; + // A `.wmo` argument is taken as a literal chain path, but only if the chain HAS it: `stormwind.wmo` + // ends in `.wmo` and is not a path, and the old "ends_with ⇒ literal" rule turned that into a hard + // read error. Read as "no groups affected" from a grepped log, it cost a wrong all-clear on + // Stormwind, Ironforge and Stratholme. Falling back to the substring search cannot mislead: the + // resolved path is printed, and a genuine miss still errors. + let lower = pat.to_lowercase(); + let path = if lower.ends_with(".wmo") && chain.read_file(&lower).is_ok() { + pat.clone() + } else { + 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}"); + + let root_bytes = chain.read_file(&path.to_ascii_lowercase())?; + let ParsedWmo::Root(root) = parse_wmo(&mut std::io::Cursor::new(&root_bytes))? else { + anyhow::bail!("{path} is a group file, want the root"); + }; + // MOHD: nTextures, nGroups, nPortals, nLights, nDoodadNames, nDoodadDefs, nDoodadSets (7×u32), + // then ambColor @0x1c, wmoID @0x20, bbox @0x24 (24 B), flags @0x3c. + if let Some(h) = chunk(&root_bytes, b"DHOM") { + let at = |i: usize| u32::from_le_bytes([h[i], h[i + 1], h[i + 2], h[i + 3]]); + println!( + "MOHD: {} groups, {} portals, {} lights, {} doodad defs ambColor {:#010x} \ + wmoID {} flags {:#06x}", + at(4), + at(8), + at(0xc), + at(0x14), + at(0x1c), + at(0x20), + if h.len() >= 0x40 { at(0x3c) } else { 0 }, + ); + } + println!( + "{} materials, {} groups", + root.materials.len(), + root.n_groups + ); + + let wmo_root = benilla_formats::parse_wmo_root(&root_bytes)?; + let survey = filter.as_deref() == Some("--survey"); + + let want_group: Option = filter + .as_deref() + .and_then(|f| f.strip_prefix('g')) + .and_then(|n| n.parse().ok()); + let want_point: Option<[f32; 3]> = + filter.as_deref().filter(|f| f.contains(',')).and_then(|f| { + let v: Vec = f.split(',').filter_map(|s| s.trim().parse().ok()).collect(); + (v.len() == 3).then(|| [v[0], v[1], v[2]]) + }); + + let stem = &path[..path.len() - 4]; + for gi in 0..root.n_groups as usize { + if want_group.is_some_and(|w| w != gi) { + continue; + } + let gpath = format!("{stem}_{gi:03}.wmo"); + let Ok(gbytes) = chain.read_file(&gpath.to_ascii_lowercase()) else { + continue; + }; + let Ok(ParsedWmo::Group(group)) = parse_wmo(&mut std::io::Cursor::new(&gbytes)) else { + continue; + }; + // MOGP header: flags @0x08, bbox min @0x0c / max @0x18, portal span @0x24/0x26, and the + // batch-section counts TRANS @0x28 / INT @0x2a (MOBA is laid out TRANS, INT, EXT). + let Some(mogp) = chunk(&gbytes, b"PGOM").filter(|m| m.len() >= 0x2c) else { + continue; + }; + let f32_at = + |i: usize| f32::from_le_bytes([mogp[i], mogp[i + 1], mogp[i + 2], mogp[i + 3]]); + let bb = [ + f32_at(0x0c), + f32_at(0x10), + f32_at(0x14), + f32_at(0x18), + f32_at(0x1c), + f32_at(0x20), + ]; + if let Some(p) = want_point { + if !(0..3).all(|i| p[i] >= bb[i] - 1.0 && p[i] <= bb[i + 3] + 1.0) { + continue; + } + } + let trans_n = u16::from_le_bytes([mogp[0x28], mogp[0x29]]) as usize; + let int_n = u16::from_le_bytes([mogp[0x2a], mogp[0x2b]]) as usize; + let interior = (group.flags & 0x48) == 0; + let has_colors = group.vertex_colors.len() == group.vertex_positions.len(); + println!( + "\n=== g{gi:03} flags {:#010x} {} portals {} bbox ({:.0},{:.0},{:.0})..\ + ({:.0},{:.0},{:.0}) {} verts MOCV {} {} batches (trans {trans_n}, int {int_n})", + group.flags, + if interior { "INTERIOR" } else { "exterior" }, + u16::from_le_bytes([mogp[0x26], mogp[0x27]]), + bb[0], + bb[1], + bb[2], + bb[3], + bb[4], + bb[5], + group.vertex_positions.len(), + if has_colors { "yes" } else { "ABSENT" }, + group.render_batches.len(), + ); + // The fade's blast radius on this group: how many authored slots it rewrites, and by how much + // — the number that says whether the doorway fixup is a targeted lift or a wash over the room. + if let Some(fixed) = benilla_formats::wmo_group_fixed_colors(&gbytes, &wmo_root) { + let lum = |c: [f32; 3]| 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; + let (mut changed, mut before, mut after) = (0usize, 0.0f32, 0.0f32); + for (raw, f) in group.vertex_colors.iter().zip(&fixed) { + let (b, a) = ( + lum([raw.r as f32, raw.g as f32, raw.b as f32]), + lum([f[2] as f32, f[1] as f32, f[0] as f32]), + ); + before += b; + after += a; + if [raw.b, raw.g, raw.r, raw.a] != *f { + changed += 1; + } + } + let n = fixed.len().max(1) as f32; + println!( + " doorway fade: {changed}/{} verts rewritten, mean lum {:.0} → {:.0}", + fixed.len(), + before / n, + after / n + ); + } + if survey { + continue; + } + for (bi, batch) in group.render_batches.iter().enumerate() { + let class = if bi < trans_n { + "TRANS" + } else if bi < trans_n + int_n { + "INT" + } else { + "EXT" + }; + let material = root.materials.get(batch.material_id as usize); + let tex = material + .map(|m| m.get_texture1_index(&root.texture_offset_index_map)) + .and_then(|i| root.textures.get(i as usize)) + .map(String::as_str) + .unwrap_or("(none)"); + let start = batch.start_index as usize; + let idx = group + .vertex_indices + .get(start..start + batch.count as usize) + .unwrap_or(&[]); + // Split by facing: a floor's up-facing verts are what a "dark floor" report is about. + let (mut up, mut other) = (Stat::new(), Stat::new()); + for &i in idx { + let i = i as usize; + let Some(c) = group.vertex_colors.get(i) else { + continue; + }; + let c = [c.r as f32, c.g as f32, c.b as f32, c.a as f32]; + if group.vertex_normals.get(i).is_some_and(|n| n.z > 0.7) { + up.add(c); + } else { + other.add(c); + } + } + println!( + " b{bi:3} {class:<5} mat{:<3} flags {:#06x} blend {} {tex}", + batch.material_id, + material.map_or(0, |m| m.flags), + material.map_or(0, |m| m.blend_mode), + ); + println!(" up-facing {}", up.line()); + println!(" other {}", other.line()); + } + // The same census *after* the reader's bright-doorway fade — what the renderer actually + // uploads. A batch that reads black above and white here is a floor the file never baked and + // the runtime fixup lights (a WMO transition corridor); one that stays black in both is a + // genuinely dark room. + let subs = benilla_formats::wmo_group_submeshes(&gbytes, &wmo_root)?; + println!( + " --- after FixColorVertexAlpha ({} submeshes) ---", + subs.len() + ); + for (si, s) in subs.iter().enumerate() { + let (mut up, mut other) = (Stat::new(), Stat::new()); + for (vi, c) in s.vertex_colors.iter().enumerate() { + let c = c.map(|v| v * 255.0); + if s.normals.get(vi).is_some_and(|n| n[2] > 0.7) { + up.add(c); + } else { + other.add(c); + } + } + println!( + " s{si:3} {} up-facing {}", + s.texture.as_deref().unwrap_or("(none)"), + up.line() + ); + println!(" other {}", other.line()); + } + } + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/chaincensus.rs b/crates/benilla-formats/src/bin/benilla-extract/chaincensus.rs new file mode 100644 index 00000000..56cc63f5 --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/chaincensus.rs @@ -0,0 +1,142 @@ +//! `chaincensus`: the whole-table view of the **beam/chain** system (decision 0955) — every +//! `SpellVisualKit` that draws a `SpellChainEffects` beam, the row it names, and the spells that +//! reach it through which lifecycle stage. +//! +//! The scope instrument for B161 ("Chain Lightning has no chain effect"). It answers, from the +//! shipped table rather than from expectation, the two questions that turned that one report into +//! a system: *which* spells are affected (not just Chain Lightning — every drain, funnel and eye +//! beam in the game), and whether the small-int decode of `CharParamZero` is the real mechanism — +//! because if it is, **every** live slot must resolve to a real row of an 18-row table and name a +//! texture that matches the spell. A decode that were merely plausible would scatter. +//! +//! Read the `stage` column with the flag: `channel` kits ship flag 1, `cast` kits flag 0, and that +//! flag — not the type key's name — is the only thing the client distinguishes. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::Result; +use benilla_formats::{char_proc_type, Chain, SpellVisualCatalog, VisualStages}; + +/// A `SpellVisual` stage's column selector — one of the five lifecycle-kit fields. +type StagePick = fn(&VisualStages) -> u32; + +const STAGES: [(&str, StagePick); 5] = [ + ("precast", |s| s.precast), + ("cast", |s| s.cast), + ("impact", |s| s.impact), + ("state", |s| s.state), + ("channel", |s| s.channel), +]; + +/// Which spells reach one kit, and through which lifecycle stages. +#[derive(Default, Clone)] +struct Reach { + spells: BTreeSet, + stages: BTreeSet<&'static str>, +} + +/// Every kit → the spells that reach it, and by which stage. +fn kit_reachability( + chain: &mut Chain, + visuals: &SpellVisualCatalog, +) -> Result> { + let spells = benilla_formats::load_spell_catalog(chain)?; + let mut out: BTreeMap = BTreeMap::new(); + for (_, spell) in spells.iter() { + let Some(stages) = visuals.stages(spell.visual) else { + continue; + }; + for (label, pick) in STAGES { + let kit = pick(stages); + if kit == 0 { + continue; + } + let e = out.entry(kit).or_default(); + e.spells.insert(spell.name.clone()); + e.stages.insert(label); + } + } + Ok(out) +} + +/// Census the beam system: the 18-row effect table, then every kit that draws one. +pub fn run(chain: &mut Chain) -> Result<()> { + let visuals = benilla_formats::load_spell_visual_catalog(chain)?; + let reach = kit_reachability(chain, &visuals)?; + + println!( + "SpellChainEffects: {} rows\n{:>4} {:>8} {:>6} {:>7} {:>7} {:>7} {:>6} texture", + visuals.chain_effect_len(), + "id", + "segLen", + "halfWid", + "noise", + "scrollS", + "segDur", + "segDly", + ); + for id in 0..=255 { + let Some(c) = visuals.chain_effect(id) else { + continue; + }; + println!( + "{id:>4} {:>8} {:>6} {:>7} {:>7} {:>7} {:>6} {}", + c.avg_seg_len, + c.half_width, + c.noise_scale, + c.scroll_period_s, + c.seg_duration_ms, + c.seg_delay_ms, + c.texture, + ); + } + + println!( + "\n{:>5} {:>4} {:>5} {:>5} {:>4} {:<9} {:<26} spells", + "kit", "type", "chain", "beams", "flag", "stage", "texture", + ); + let (mut live, mut padding, mut spells_hit) = (0usize, 0usize, BTreeSet::new()); + for kit_id in visuals.kit_ids() { + let Some(kit) = visuals.kit(kit_id) else { + continue; + }; + for proc in kit.char_procs().filter(|p| char_proc_type::is_chain(p.ty)) { + let Some(c) = proc.as_chain() else { + padding += 1; + continue; + }; + live += 1; + let Reach { spells, stages } = reach.get(&kit_id).cloned().unwrap_or_default(); + spells_hit.extend(spells.iter().cloned()); + let texture = visuals + .chain_effect(c.effect_id) + .map_or("(NO SUCH ROW)", |e| { + e.texture.rsplit('\\').next().unwrap_or(&e.texture) + }); + let listed: Vec<&str> = spells.iter().take(6).map(String::as_str).collect(); + let more = spells.len().saturating_sub(listed.len()); + println!( + "{kit_id:>5} {:>4} {:>5} {:>5} {:>4} {:<9} {texture:<26} {}{}", + c.ty, + c.effect_id, + c.beams, + u8::from(c.flag), + stages.iter().copied().collect::>().join(","), + listed.join(", "), + if more > 0 { + format!(" … (+{more})") + } else { + String::new() + }, + ); + } + } + println!( + "\n{live} live beams across the table ({padding} zero-param padding slots no-oped), \ + reaching {} named spells.\n\ + Every live slot above resolves to a real SpellChainEffects row — that is the check: a \ + wrong decode would not.", + spells_hit.len(), + ); + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs b/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs index d5c7e872..dcb841a9 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/charprocs.rs @@ -31,6 +31,8 @@ fn type_name(ty: i32) -> &'static str { char_proc_type::TINT => "TINT (body RGB)", char_proc_type::ALPHA => "ALPHA (body translucency)", char_proc_type::ANIM_RATE => "ANIM_RATE (body anim playback rate — 0 freezes the pose)", + char_proc_type::CHAIN_CHANNEL => "CHAIN (beam; the channel-stage key — 0955)", + char_proc_type::CHAIN_CAST => "CHAIN (beam; the cast-stage key — 0955)", _ => "(unmodelled)", } } diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index 6b34ee4a..f64b2c53 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -15,6 +15,7 @@ use anyhow::{Context, Result}; use benilla_formats::open_chain; use clap::{Parser, Subcommand}; +mod chaincensus; mod charprocs; mod m2dump; mod scan; @@ -66,6 +67,12 @@ enum Command { /// The `Spell.dbc` id (e.g. 133 = Fireball). spell_id: u32, }, + /// Census the **beam/chain** system (decision 0955): the 18-row `SpellChainEffects` table, + /// then every `SpellVisualKit` that draws a beam — the row it names, its beam count and + /// flag, and the spells that reach it through which lifecycle stage. The scope instrument + /// for B161 ("Chain Lightning has no chain effect"), and the check that the `CharParamZero` + /// small-int decode is the real mechanism: every live slot must land on a real row. + Chaincensus, /// Census the `SpellVisualKit` **CharProc** columns (a kit's effect on the BODY — its alpha, /// its tint): which proc types the shipped table carries, which lifecycle stage reaches each /// from a live spell, and every state-stage (aura-lifetime) proc in full. The scope instrument @@ -700,6 +707,7 @@ fn main() -> Result<()> { } => scan::doodadscan(&mut chain, &map, center_x, center_y, tile_radius)?, Command::Shadeat { map, x, y } => scan::shadeat(&mut chain, &map, x, y)?, Command::Spellvis { spell_id } => spellvis::run(&mut chain, spell_id)?, + Command::Chaincensus => chaincensus::run(&mut chain)?, Command::Charprocs => charprocs::run(&mut chain)?, } diff --git a/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs b/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs index dc2976c5..5f0c221d 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs @@ -39,6 +39,30 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> { ); // The kit's CharProcs (fields 15-34): what it does to the BODY. crate::charprocs::print_kit_procs(&visuals, kit_id, " "); + // The kit's BEAM, if it draws one (decision 0955): the chain CharProc's + // decoded `SpellChainEffects` row. `benilla-extract … chaincensus` is the + // whole-table view. + if let Some(c) = kit.chain_proc() { + match visuals.chain_effect(c.effect_id) { + Some(e) => println!( + " beam chain {} x{} flag={} -> {} (segLen {} halfWidth {} noise {} scroll {}s life {}ms, delay {}ms dead)", + c.effect_id, + c.beams, + u8::from(c.flag), + e.texture, + e.avg_seg_len, + e.half_width, + e.noise_scale, + e.scroll_period_s, + e.seg_duration_ms, + e.seg_delay_ms, + ), + None => println!( + " beam chain {} (NO SUCH SpellChainEffects ROW)", + c.effect_id + ), + } + } // The kit's attach-point emitter slots (phase 3): tag + effect model. for (tag, effect) in kit.effects() { println!( diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 51665038..9f16b4e6 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -133,8 +133,9 @@ mod talents; pub use talents::{load_talent_catalog, Talent, TalentCatalog, TalentTabInfo, MAX_TALENT_RANK}; mod spell_visual; pub use spell_visual::{ - char_proc_type, load_spell_visual_catalog, CharProc, SpellVisualCatalog, VisualKit, - VisualStages, KIT_CHAR_PROCS, KIT_SLOT_TAGS, MISSILE_ATTACH_TABLE, WORLD_EFFECT_TAG, + char_proc_small_int, char_proc_type, load_spell_visual_catalog, ChainEffect, ChainProc, + CharProc, SpellVisualCatalog, VisualKit, VisualStages, CHAIN_MAX_BEAMS, KIT_CHAR_PROCS, + KIT_SLOT_TAGS, MISSILE_ATTACH_TABLE, WORLD_EFFECT_TAG, }; mod emit_timing; pub use emit_timing::{EmitParams, EmitTiming, ParamsNow}; @@ -160,14 +161,15 @@ pub use models::{ parse_m2_lights, parse_m2_playable_animation_lookup, parse_m2_portrait_camera, parse_m2_render_submeshes, parse_m2_skeleton, parse_m2_string_anchors, parse_wmo_fogs, parse_wmo_lights, parse_wmo_portals, parse_wmo_root, wmo_group_doodad_refs, - wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs, wmo_group_liquid_mesh, - wmo_group_submeshes, wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent, Billboard, BillboardKind, - BoneKeys, BoneScaleAnim, CharSkinSlot, CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy, - FootprintTris, GlobalSeqBone, GlobalSeqChannel, GroundQuad, M2AnimSummary, M2Attachment, - M2Bounds, M2Light, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis, - PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, Skeleton, SkeletonBone, StringAnchors, - UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, - WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot, OWNER_RUNG_BUCKETS, + wmo_group_fixed_colors, wmo_group_footprint_tris, wmo_group_header, wmo_group_light_refs, + wmo_group_liquid_mesh, wmo_group_raw_colors, wmo_group_submeshes, wmo_root_id, AlphaAnim, + AlphaSeq, AnimEvent, Billboard, BillboardKind, BoneKeys, BoneScaleAnim, CharSkinSlot, + CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris, GlobalSeqBone, + GlobalSeqChannel, GroundQuad, M2AnimSummary, M2Attachment, M2Bounds, M2Light, M2PortraitCamera, + ModelAnimation, ModelBlend, ParentArm, ParentBasis, PlayableAnim, RenderSubmesh, RgbAnim, + ScalarAnim, Skeleton, SkeletonBone, StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, + WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, + WmoPortals, WmoRoot, OWNER_RUNG_BUCKETS, }; mod terrain; pub use terrain::{ diff --git a/crates/benilla-formats/src/models/wmo/group.rs b/crates/benilla-formats/src/models/wmo/group.rs index a681fa79..f5420ed9 100644 --- a/crates/benilla-formats/src/models/wmo/group.rs +++ b/crates/benilla-formats/src/models/wmo/group.rs @@ -4,7 +4,7 @@ use std::io::Cursor; use anyhow::Result; -use benilla_wmo::{parse_wmo, ParsedWmo, WmoLiquid}; +use benilla_wmo::{parse_wmo, Color, ParsedWmo, WmoGroup, WmoLiquid}; use super::{find_wmo_chunk, WmoRoot}; use crate::liquid::{LiquidKind, LiquidMesh}; @@ -318,16 +318,218 @@ fn build_wmo_liquid_mesh(lq: &WmoLiquid, group_liquid: u32) -> Option> 14`, never +/// numerically converted, so the byte falls out of the mantissa's low byte (`[0x8029cc]`). +const FIXED_BIAS: f64 = 512.0; + +/// Distance from a model-space point to one portal polygon, the reference's kernel (`0x6c4240`): +/// **0** when the point projects *through the opening* — inside the polygon and within +/// [`PORTAL_PLANE_EPS`] of its plane — otherwise the minimum distance to the polygon's edge segments. +/// +/// The containment half is what keeps a doorway's plane from reaching across a room. A portal plane is +/// infinite; several of these openings are not doorways at all but whole ceilings (Dire Maul's sunken +/// courtyard joins its sky through a 283×154 yd quad), so "on the plane" alone would whiten geometry +/// tens of yards from any opening. Points on the plane but *outside* the outline fall through to the +/// edge distance, which is the reference's own fallback and is never smaller than the truth. +fn portal_distance(p: [f32; 3], poly: &[[f32; 3]], plane: [f32; 4]) -> f32 { + let n = [plane[0], plane[1], plane[2]]; + let pd = n[0] * p[0] + n[1] * p[1] + n[2] * p[2] + plane[3]; + let mut best = f32::MAX; + let mut inside = pd * pd < PORTAL_PLANE_EPS * PORTAL_PLANE_EPS; + let proj = [p[0] - n[0] * pd, p[1] - n[1] * pd, p[2] - n[2] * pd]; + for i in 0..poly.len() { + let (a, b) = (poly[i], poly[(i + 1) % poly.len()]); + let e = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + // Outward edge test about the plane normal: a projection left of every edge is inside. + let out = [ + e[1] * n[2] - e[2] * n[1], + e[2] * n[0] - e[0] * n[2], + e[0] * n[1] - e[1] * n[0], + ]; + let w = [proj[0] - a[0], proj[1] - a[1], proj[2] - a[2]]; + if out[0] * w[0] + out[1] * w[1] + out[2] * w[2] > 0.0 { + inside = false; + } + let wp = [p[0] - a[0], p[1] - a[1], p[2] - a[2]]; + let len2 = e[0] * e[0] + e[1] * e[1] + e[2] * e[2]; + let t = if len2 > 0.0 { + ((wp[0] * e[0] + wp[1] * e[1] + wp[2] * e[2]) / len2).clamp(0.0, 1.0) + } else { + 0.0 + }; + let d = [wp[0] - e[0] * t, wp[1] - e[1] * t, wp[2] - e[2] * t]; + best = best.min((d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()); + } + if inside { + 0.0 + } else { + best + } +} + +/// One byte through the reference's fixed-point white-lerp: `((255 − ch)·t + ch + 512)` as f32, whose +/// **bit pattern** shifted `>> 14` is the new byte (`0x6c43d0`, byte-verified against `WoW.exe`). +fn white_lerp_byte(ch: u8, t: f64) -> u8 { + let chf = f64::from(ch); + let s = ((255.0 - chf) * t + chf + FIXED_BIAS) as f32; + ((s.to_bits() >> 14) & 0xff) as u8 +} + +/// **FixColorVertexAlpha** (`0x6c43d0`, wow-re `wmo-group-lighting.md §4`) — the bright-doorway portal +/// fade, run once per group *before* it is ever drawn, in place over its MOCV buffer. +/// +/// This is not a nicety: a WMO's short transition corridors are authored with **no usable bake at +/// all** — Dire Maul's five entrance passages floor their walkway at MOCV `(10,10,40,α=0)`, a near-black +/// navy — because the runtime fade is what lights them. Skip it and the interior TRANS law renders +/// exactly what the file says: `tex × (10,10,40)` at lit-factor 1, a black floor between lit walls +/// (the director's Dire Maul entrance report). The floor's four corners sit *on* the doorway portal +/// polygons, so the fade takes them to white and the quad interpolates lit end to end. +/// +/// The mechanism: +/// 1. **Distance**: per vertex, the minimum [`portal_distance`] over this group's portal refs whose +/// **neighbour group is exterior** (`MOGI.flags & 0x48 != 0`). Portals to interior neighbours +/// whiten nothing — the fade is specific to interior↔exterior openings, observed live. A group +/// with no such portal is untouched. +/// 2. **Branches**: `dist == 0` ⇒ the slot goes white-opaque outright; `dist < 6.6667` **and** the +/// authored alpha is 0 ⇒ RGB white-lerped by `t = 1 − 0.15·dist` with alpha `t·255`; else untouched. +/// +/// **The MOPY pre-pass (`0x6c41e0`) is deliberately NOT run**, on live evidence over the static read. +/// §4 records it as forcing `alpha = 0xFF` on every triangle with `MOPY.flags & 1 == 0` — but it is +/// gated (`DAT_00ca8064 == 0`), and the reference's own D3D capture of the abbey +/// (`trace-forensics-abbey-interior-d3d.md` §2) shows the uploaded MOCV pool **byte-exact against the +/// file except for the whitened set** — 678/678 and 496/506 vertices identical, alphas included. A +/// pre-pass that had run would have rewritten hundreds of alphas in that same buffer (372 of g003's +/// 506 by this reader's count). It costs nothing here either way: the pre-pass can only *suppress* the +/// partial-lerp branch, never the `dist == 0` whitening the dark corridors depend on. +/// +/// A group without MOCV, or without an exterior-neighbour portal, is left exactly as authored. +fn fix_color_vertex_alpha( + colors: &mut [Color], + group: &WmoGroup, + root: &WmoRoot, + header_refs: (u16, u16), +) { + let portals = root.portals(); + let infos = root.group_infos(); + let (start, count) = (header_refs.0 as usize, header_refs.1 as usize); + // The doorway set: portals of THIS group whose far side is an exterior (daylit) group. + let doors: Vec<(Vec<[f32; 3]>, [f32; 4])> = portals + .refs + .get(start..start + count) + .unwrap_or(&[]) + .iter() + .filter(|r| infos.get(r.group as usize).is_some_and(|g| !g.interior)) + .filter_map(|r| portals.infos.get(r.portal as usize)) + .map(|info| { + let v = (0..info.count as usize) + .filter_map(|k| { + portals + .vertices + .get(info.start_vertex as usize + k) + .copied() + }) + .collect(); + (v, info.plane) + }) + .filter(|(v, _): &(Vec<[f32; 3]>, _)| v.len() >= 3) + .collect(); + if doors.is_empty() { + return; + } + for (i, c) in colors.iter_mut().enumerate() { + let Some(p) = group.vertex_positions.get(i) else { + continue; + }; + let p = [p.x, p.y, p.z]; + let dist = doors + .iter() + .map(|(poly, plane)| portal_distance(p, poly, *plane)) + .fold(f32::MAX, f32::min); + if dist == 0.0 { + *c = Color { + b: 0xff, + g: 0xff, + r: 0xff, + a: 0xff, + }; + } else if dist < DOORWAY_FADE_REACH && c.a == 0 { + let t = 1.0 - f64::from(dist) * DOORWAY_FADE_SLOPE; + c.b = white_lerp_byte(c.b, t); + c.g = white_lerp_byte(c.g, t); + c.r = white_lerp_byte(c.r, t); + c.a = ((((t * 255.0) + FIXED_BIAS) as f32).to_bits() >> 14 & 0xff) as u8; + } + } +} + +/// The group's MOCV **as the renderer uploads it** — the authored buffer with the bright-doorway fade +/// already applied (see [`fix_color_vertex_alpha`]), BGRA bytes parallel to the group's vertices. +/// `None` when the bytes aren't a group or carry no MOCV parallel to their positions. +/// +/// The seam the `wmo_mocv` instrument reads: a batch that censuses black in the file and white here +/// is a transition corridor the fade lights; black in both is a genuinely dark room. +pub fn wmo_group_fixed_colors(group_bytes: &[u8], root: &WmoRoot) -> Option> { + let Ok(ParsedWmo::Group(group)) = parse_wmo(&mut Cursor::new(group_bytes)) else { + return None; + }; + let colors = fixed_colors(&group, group_bytes, root)?; + Some(colors.iter().map(|c| [c.b, c.g, c.r, c.a]).collect()) +} + +/// The group's MOCV **exactly as authored**, BGRA bytes parallel to its vertices — the before to +/// [`wmo_group_fixed_colors`]'s after. `None` when the bytes aren't a group or carry no MOCV parallel +/// to their positions. +pub fn wmo_group_raw_colors(group_bytes: &[u8]) -> Option> { + let Ok(ParsedWmo::Group(group)) = parse_wmo(&mut Cursor::new(group_bytes)) else { + return None; + }; + (group.vertex_colors.len() == group.vertex_positions.len()).then(|| { + group + .vertex_colors + .iter() + .map(|c| [c.b, c.g, c.r, c.a]) + .collect() + }) +} + +/// Shared body of [`wmo_group_fixed_colors`] and the submesh build: clone the authored MOCV and run +/// the fade over it. `None` when the group carries no MOCV parallel to its positions. +fn fixed_colors(group: &WmoGroup, group_bytes: &[u8], root: &WmoRoot) -> Option> { + if group.vertex_colors.len() != group.vertex_positions.len() { + return None; + } + let mut colors = group.vertex_colors.clone(); + let refs = + wmo_group_header(group_bytes).map_or((0, 0), |h| (h.portal_ref_start, h.portal_ref_count)); + fix_color_vertex_alpha(&mut colors, group, root, refs); + Some(colors) +} + /// Build the render submeshes for one WMO **group** (its bytes), resolving textures/materials against /// `root`. Empty if the bytes aren't a parseable group. The bytes-in entry point for the Bevy /// `AssetLoader`; [`super::load_wmo`] is the chain-reading wrapper. pub fn wmo_group_submeshes(group_bytes: &[u8], root: &WmoRoot) -> Result> { - let ParsedWmo::Root(root) = &root.parsed else { - anyhow::bail!("WmoRoot is not a root"); - }; let Ok(ParsedWmo::Group(group)) = parse_wmo(&mut Cursor::new(group_bytes)) else { return Ok(Vec::new()); }; + // The bright-doorway fade runs ONCE per group, over the group's own MOCV buffer, before any draw + // reads it (`0x6b3f90` gates it on the group's `+0xbc` done-byte). We load a group once, so the + // once-gate is the load itself: fix the colours here and every batch below sees the fixed buffer. + let colors = fixed_colors(&group, group_bytes, root).unwrap_or_default(); + let ParsedWmo::Root(root) = &root.parsed else { + anyhow::bail!("WmoRoot is not a root"); + }; // The MOGP batch-section counts (`+0x28/+0x2a/+0x2c`, after the byte-verified portal-ref span): // MOBA batches are laid out TRANS, then INT, then EXT — the class decides an interior group's // per-batch lighting law (see [`WmoBatchClass`]). Absent/short header ⇒ everything reads EXT @@ -344,7 +546,7 @@ pub fn wmo_group_submeshes(group_bytes: &[u8], root: &WmoRoot) -> Result Result> 14 & 0xff` — the same idiom already pinned at `0x5d55c0` for the +//! dynobject shard index ([`super::char_proc_small_int`]): +//! +//! | column | decoded to | +//! |---|---| +//! | `CharParamZero` | the **`SpellChainEffects` id** — this table's key | +//! | `CharParamOne` | the **beam count**, clamped to `≤ 3` by `0x6ecbd0` ([`CHAIN_MAX_BEAMS`]) | +//! | `CharParamTwo` | a **boolean flag** (`setne`), stored at the beam node's `+0x48` | +//! +//! **The decode is corroborated by prediction, not just by reading:** run it over all 68 type-0/12 +//! slots in the shipped `SpellVisualKit.dbc` and every one of the 48 non-zero results lands on a +//! real row of this 18-row table — no misses, no out-of-range — and each names the *right* texture: +//! Drain Mana and Mind Flay → `ManaBeam`, Drain Soul → `Beam_Purple`, Drain Life and Health Funnel +//! → `SoulBeam`, Chain Heal → `HealBeam`, Chain Lightning → `Lightning`, Shrink Ray → +//! `ManaBurnBeam`, C'Thun's Eye Beam → `SoulBeam`. The other 20 decode to id `0`, which has no row +//! — the client's own bounds check (`jl` / `cmp maxId` / null-row test at `0x6ecc0e`–`0x6ecc30`) +//! no-ops them, so they are padding and [`ChainProc`] reports them as absent. +//! +//! The shipped split is a *data* convention, not a code fork: type 0 appears on **channel**-stage +//! kits with the flag decoding to 1, type 12 on **cast**-stage kits with the flag decoding to 0. +//! The dispatcher itself cannot tell them apart, so a consumer keys off [`ChainProc::flag`], never +//! off the type constant's name. +//! +//! ## Layout — VERIFIED against build 5875 +//! +//! **18 records × 8 fields × 32 B** (header dumped from the extracted file; wow-re +//! `system/dbc/scratch/spellvisual-schema.md` records the same shape from the loader's own +//! field-count assert, plus the client-DB globals `records 0xc0d848 / idIndex 0xc0d850 / +//! maxId 0xc0d854`, loader `0x54e980`). Ids are `1..=13, 15, 17..=20` — note `14` and `16` are +//! absent, so an id is a lookup, never an index. +//! +//! **The columns — VERIFIED** (wow-re `system/spell/scratch/chain-beam-law.md`, the §5 dispatched +//! with 0955; 4 agents, every contested point re-read at the bytes). Two of the community names +//! this module first carried were **wrong**, and both mattered: +//! +//! | field | name | meaning | +//! |---|---|---| +//! | 1 | `AvgSegLen` | segment length, with a floor: `n = trunc(len / field1 + 2.0)` segments, `n+1` points (`0x7af713`, the `2.0f` at `0x801628`) — so even a 1-yard beam gets 2 segments | +//! | 2 | `Width` | **HALF**-width — the visible ribbon is `2 × field2` yards | +//! | 3 | `NoiseScale` | jitter amplitude as a **fraction of beam length** (`len × field3`), **re-rolled every frame** (`0x7b0950` dirties the geometry per frame), with a `0.75/0.25` advection blend against the previous roll | +//! | 4 | ~~`TexCoordScale`~~ **`ScrollPeriod`** | **not a scale at all** — the scroll **period in seconds**: `phase = fmod(phase + dt, field4)` (`0x7af9d7`), `u = -(phase / field4)` (`0x7b055f`). A **negative** value reverses the scroll direction, magnitude unchanged — which is why the four *drain* rows ship `-0.5`: the texture flows back toward the caster | +//! | 5 | `SegDuration` (ms) | the beam's life: expiry = `now + segmentCount × field5`, and **only for a one-shot** (see the flag below). The client also stores a seconds-scaled copy (`× 0.001f`, the constant at `0x801360`) which it then **never reads** | +//! | 6 | `SegDelay` (ms) | **never reaches the renderer** — read from the row, dead thereafter | +//! | 7 | `Texture` | the beam's texture | +//! +//! The beam is **never tinted**: its colour word is always `0xFFFFFFFF`. Render state, settled +//! against wow-re's own verified `EGxRs` map: **additive `SRC_ALPHA/ONE`, two-sided, depth-write +//! OFF, fog off, emissive white, alpha-test `GEQUAL 1/255`**. +//! +//! **Geometry:** a chain is one polyline — `count+1` points, `count` segments — running **caster → +//! t1 → t2 → t3**, never a fan and never one beam per target (the client's own `Bolt` records name +//! `idxA = i`, `idxB = i+1`). The endpoints are **re-resolved from the live units every frame** +//! (`0x6ec460`), so a beam tracks a moving caster and a moving target. The caster's end anchors at +//! its `$CSL` attachment or, failing that, `0.75 × modelHeight × modelScale` (`0x6ec6f0`); every +//! other endpoint anchors at an M2 attachment resolved from the spell's own `SpellVisual` field 9 +//! — the **same dest-attachment ordinal the missile uses** ([`MISSILE_ATTACH_TABLE`]) — with +//! attachment 34 as the fallback. +//! +//! **Lifetime** is the flag's, and only the flag's: a beam is dead when `flag == 0 && now >= +//! expiry`. So a **cast** beam (flag 0) self-terminates after `segmentCount × SegDuration`, and a +//! **channel** beam (flag 1) never expires by time at all — it ends only when the owning kit node +//! is swept (`LightningObject::Stop 0x6ece10`, one caller image-wide). +//! +//! **The strand count is not a variation knob.** All three strands are constructed from +//! byte-identical arguments — no seed, no index, no stagger. They differ only in heap identity, +//! and so in the draws each takes from the shared PRNG. (Construction VERIFIED; that this is what +//! produces the braided look is INFERRED.) +//! +//! Id **15** is degenerate — width, noise, scroll period and both timings are all `0`, with only +//! `AvgSegLen = 5.0` and a texture. No shipped kit reaches it. Read the table with +//! `benilla-extract chaincensus`, which prints it alongside every kit that draws a beam; +//! it is also what caught a hand-decode of `SegDelay` being wrong. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, str_at, u32_at}; +use crate::Chain; + +const SPELL_CHAIN_EFFECTS: &str = "DBFilesClient\\SpellChainEffects.dbc"; +const SPELL_CHAIN_EFFECTS_FIELDS: usize = 8; + +/// The client's own clamp on a chain proc's beam count (`0x6ecbd0`: `cmp esi,0x3` → `jbe` → +/// `mov [ebp+0x14],3`). Only one shipped kit asks for more than one beam — 6397 "Chain Burn", +/// which asks for exactly 3. +pub const CHAIN_MAX_BEAMS: u32 = 3; + +/// One `SpellChainEffects.dbc` row — the shape and animation of one beam. Every column's meaning +/// is VERIFIED (module docs); two of the community names were wrong and are not used here. +#[derive(Debug, Clone, PartialEq)] +pub struct ChainEffect { + /// Field 1 — the target length of one segment. The subdivision has a floor of two: + /// `segments = trunc(length / avg_seg_len + 2.0)`, points = `segments + 1`. + pub avg_seg_len: f32, + /// Field 2 — the beam's **half**-width; the drawn ribbon spans `2 × this` yards. + pub half_width: f32, + /// Field 3 — per-joint jitter amplitude as a **fraction of the beam's length**, re-rolled + /// **every frame** and blended `0.75` previous / `0.25` new. `0.001` on the beams that read as + /// ropes, `0.04`/`0.05` on the lightnings. + pub noise_scale: f32, + /// Field 4 — the texture **scroll period in seconds** (`u = -(phase / this)`, `phase` + /// advancing by `dt` modulo this). **Negative reverses the direction**, magnitude unchanged — + /// the four drain rows ship `-0.5`, flowing the texture back toward the caster. + pub scroll_period_s: f32, + /// Field 5 — milliseconds. The beam's life: expiry = `now + segments × this`, which binds + /// **only a one-shot (cast) beam** — a channel beam ignores it and lives until swept. + pub seg_duration_ms: u32, + /// Field 6 — milliseconds. `200` on most rows, `300` on id 1, `0` on the three rows no kit + /// reaches — and **dead**: the §5 traced it no further than the row. Kept because it is a + /// column, read by nothing. + pub seg_delay_ms: u32, + /// Field 7 — the beam's texture, as the DBC stores it (`Textures\SpellChainEffects\*.blp`). + pub texture: String, +} + +/// A kit's chain CharProc, decoded — what [`super::VisualKit::chain_proc`] yields. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChainProc { + /// `CharParamZero` decoded — the [`ChainEffect`] id. Never `0`: an id that names no row is + /// the client's own no-op, and this type is only built for a slot that decodes to a real id. + pub effect_id: u32, + /// `CharParamOne` decoded, clamped to [`CHAIN_MAX_BEAMS`] as `0x6ecbd0` clamps it. `0` is + /// possible on the shipped table and means the client draws nothing (`jbe` straight out). + pub beams: u32, + /// `CharParamTwo` decoded to a bool, exactly as the client's `setne` does. `true` on every + /// channel-stage kit, `false` on every cast-stage one — the only thing that actually + /// distinguishes the two chain proc types (module docs). + pub flag: bool, + /// The proc's type key as the table stores it — `0` or `12`. Kept for instruments and for + /// the record; behaviour must key off [`Self::flag`], since the dispatcher does not fork. + pub ty: i32, +} + +/// `SpellChainEffects.dbc` — 8 fields / 32-byte records in build 5875. +fn chain_effects_schema() -> Schema { + let mut s = Schema::new("SpellChainEffects"); + let mut add = |name: &str, ty| s.add_field(SchemaField::new(name.to_string(), ty)); + add("ID", FieldType::UInt32); + add("AvgSegLen", FieldType::Float32); + add("HalfWidth", FieldType::Float32); + add("NoiseScale", FieldType::Float32); + add("ScrollPeriod", FieldType::Float32); + add("SegDuration", FieldType::UInt32); + add("SegDelay", FieldType::UInt32); + add("Texture", FieldType::String); + debug_assert_eq!(s.fields.len(), SPELL_CHAIN_EFFECTS_FIELDS); + s +} + +/// Read `SpellChainEffects.dbc` off the patch chain. A row with an empty texture string is kept — +/// the constructor never reads the texture, so a textureless row is the renderer's problem, not +/// the loader's, and dropping it here would hide a data fact. +pub(super) fn load(chain: &mut Chain) -> Result> { + let bytes = chain + .read_file(SPELL_CHAIN_EFFECTS) + .with_context(|| format!("reading {SPELL_CHAIN_EFFECTS}"))?; + let set = parse(&bytes, chain_effects_schema(), "SpellChainEffects.dbc")?; + let mut rows = HashMap::with_capacity(set.records().len()); + for r in set.records() { + let Some(id) = u32_at(r, 0) else { continue }; + rows.insert( + id, + ChainEffect { + avg_seg_len: f32_at(r, 1).unwrap_or(0.0), + half_width: f32_at(r, 2).unwrap_or(0.0), + noise_scale: f32_at(r, 3).unwrap_or(0.0), + scroll_period_s: f32_at(r, 4).unwrap_or(0.0), + seg_duration_ms: u32_at(r, 5).unwrap_or(0), + seg_delay_ms: u32_at(r, 6).unwrap_or(0), + texture: str_at(&set, r, 7).unwrap_or_default(), + }, + ); + } + Ok(rows) +} diff --git a/crates/benilla-formats/src/spell_visual.rs b/crates/benilla-formats/src/spell_visual/mod.rs similarity index 59% rename from crates/benilla-formats/src/spell_visual.rs rename to crates/benilla-formats/src/spell_visual/mod.rs index a7dce6ce..cfc35336 100644 --- a/crates/benilla-formats/src/spell_visual.rs +++ b/crates/benilla-formats/src/spell_visual/mod.rs @@ -1,5 +1,6 @@ -//! `SpellVisual.dbc` + `SpellVisualKit.dbc` — the per-lifecycle-stage kit chain a cast plays -//! (decision 0099 phase 2's data plane; schemas pinned by decision 0107). +//! `SpellVisual.dbc` + `SpellVisualKit.dbc` + `SpellVisualEffectName.dbc` + +//! `SpellChainEffects.dbc` — the per-lifecycle-stage kit chain a cast plays +//! (decision 0099 phase 2's data plane; schemas pinned by decision 0107, the chain table by 0955). //! //! Layout — VERIFIED against build 5875 (WDBC header dump of the extracted files, 2026-07-04; //! wow-re `system/dbc/scratch/spellvisual-schema.md` @ commit `ff91e7eb`, corroborated by vmangos @@ -58,6 +59,9 @@ //! column every consumer reads — fields 3/4 are dead-by-absence, wow-re //! `spellvisual-schema.md`; the emitter *scale* comes from kit CharProc params × the quality //! tier, never from this table). +//! - **SpellChainEffects**: 18 records × 8 fields × 32 B — the **beam/arc** geometry a kit's chain +//! `CharProc` draws (Chain Lightning's lightning, Drain Life's rope, C'Thun's eye beam). Its own +//! module: [`chain_effects`], decision 0955. //! //! **The none-sentinel, found empirically here (not pinned in the wow-re note):** on the real //! table, "no value" for both field 2 (anim) and field 13 (sound) is written as **either `0` or @@ -84,6 +88,10 @@ use benilla_dbc::{FieldType, Schema, SchemaField}; use crate::dbc::{f32_at, i32_at, parse, str_at, u32_at}; use crate::Chain; +pub mod chain_effects; + +pub use chain_effects::{ChainEffect, ChainProc, CHAIN_MAX_BEAMS}; + const SPELL_VISUAL: &str = "DBFilesClient\\SpellVisual.dbc"; const SPELL_VISUAL_KIT: &str = "DBFilesClient\\SpellVisualKit.dbc"; const SPELL_VISUAL_EFFECT_NAME: &str = "DBFilesClient\\SpellVisualEffectName.dbc"; @@ -138,6 +146,43 @@ pub struct CharProc { pub params: [f32; 4], } +impl CharProc { + /// Param `i` through the client's **small-int decode** — see [`char_proc_small_int`]. The + /// procs that want an integer out of a float column all use this one idiom. + pub fn small_int(&self, i: usize) -> u32 { + char_proc_small_int(self.params[i]) + } + + /// This slot decoded as a chain/beam proc, or `None` when it is not one — either the type key + /// is not a chain key, or it is but `CharParamZero` decodes to `0`, which names no + /// `SpellChainEffects` row and is how the shipped table writes an unused slot (`chain`'s + /// module docs; the client's own null-row test at `0x6ecc2e` is the same no-op). + pub fn as_chain(&self) -> Option { + if !char_proc_type::is_chain(self.ty) { + return None; + } + let effect_id = self.small_int(0); + (effect_id != 0).then(|| ChainProc { + effect_id, + beams: self.small_int(1).min(CHAIN_MAX_BEAMS), + flag: self.small_int(2) != 0, + ty: self.ty, + }) + } +} + +/// The client's decode of a small integer stored in a `CharProc` **float** param column: +/// `bits(param + 512.0f) >> 14 & 0xff`. Adding 512 forces the exponent, parking the integer part +/// in known mantissa bits; the shift and mask lift it back out. Byte-pinned at `0x5d55c0` (the +/// dynobject shard index, wow-re `dynobject-visual-machine.md`) and used identically by the chain +/// proc for all three of its integer params (`0x60db19`–`0x60db6d`, decision 0955). +/// +/// The client applies **no bounds check** of its own — `mov cl,al` takes the byte and indexes with +/// it — so callers clamp or bounds-test as the consumer requires. +pub fn char_proc_small_int(param: f32) -> u32 { + (param + 512.0).to_bits() >> 14 & 0xff +} + /// The `CharProc` type keys benilla models, by name. The full key space is the dispatcher's 9 cases /// (`0x60d7c0`); these are the ones a live 5875 **state** kit uses — every other key is either /// cast-stage-only or unused by the shipped table (see `charprocs` in `benilla-extract`). @@ -170,6 +215,24 @@ pub mod char_proc_type { /// ships `8947848.0` = `0x888888`, which reads like a packed grey that landed in the rate /// column; it is passed through, not special-cased. pub const ANIM_RATE: i32 = 11; + + /// **Chain / beam visual**, the key the shipped table uses on **channel**-stage kits — Drain + /// Life's rope, Mind Flay's mana beam, Health Funnel, C'Thun's eye beam. Both this and + /// [`CHAIN_CAST`] land on the same dispatcher case (`0x60da79`); the name records the data's + /// convention, and behaviour keys off [`ChainProc::flag`](crate::ChainProc::flag). See the + /// `chain_effects` module docs for the whole mechanism (decision 0955). + pub const CHAIN_CHANNEL: i32 = 0; + /// **Chain / beam visual**, the key the shipped table uses on **cast**-stage kits — Chain + /// Lightning, Chain Heal, Chain Burn, Shrink Ray, the elemental Weakness debuffs. See + /// [`CHAIN_CHANNEL`]. + pub const CHAIN_CAST: i32 = 12; + + /// Whether this type key reaches the beam case. **Both** chain keys do, and the dispatcher's + /// translation table (`0x60dc20`) draws no distinction between them — so every consumer asks + /// this rather than comparing against one constant. + pub fn is_chain(ty: i32) -> bool { + ty == CHAIN_CHANNEL || ty == CHAIN_CAST + } } /// One `SpellVisual.dbc` row: the five lifecycle-stage `SpellVisualKit` ids a cast may fire @@ -267,6 +330,13 @@ impl VisualKit { self.char_procs().find(|p| p.ty == ty).map(|p| p.params[0]) } + /// This kit's chain/beam proc — the **first** slot that decodes to one, matching the client's + /// dispatcher walk (it runs every slot in order; no shipped kit carries two). `None` when the + /// kit draws no beam. See [`chain_effects`] for the mechanism (decision 0955). + pub fn chain_proc(&self) -> Option { + self.char_procs().find_map(|p| p.as_chain()) + } + /// The populated emitter slots as `(M2 attachment id, SpellVisualEffectName id)` pairs, in /// kit-field order — the client fires **all** populated slots at **every** stage (stage sets /// lifetime policy only, wow-re `spell-visual-apply.md` §1.3) — then [`Self::world_effect`] @@ -297,6 +367,9 @@ pub struct SpellVisualCatalog { /// (id 21 → `Spells\LevelUp\LevelUp.mdl`, the ding) and "HARDCODED Mount Poof" /// (id 1185 → `Spells\DruidMorph_Impact_Base.mdx`, the mount-up cloud — decision 0927). hardcoded: HashMap, + /// `SpellChainEffects` id → the beam's geometry/animation row ([`chain_effects`], decision 0955). + /// Reached only through a kit's [`VisualKit::chain_proc`]. + chain_effects: HashMap, } impl SpellVisualCatalog { @@ -319,9 +392,18 @@ impl SpellVisualCatalog { kits, effect_paths, hardcoded: HashMap::new(), + chain_effects: HashMap::new(), } } + /// Seed one `SpellChainEffects` row, for fixtures exercising the beam chain. The live path is + /// [`load_spell_visual_catalog`]. + #[must_use] + pub fn with_chain_effect(mut self, id: u32, effect: ChainEffect) -> Self { + self.chain_effects.insert(id, effect); + self + } + /// Seed one `"HARDCODED …"` name → model path, for fixtures exercising the engine-spawned /// effects (the loot sparkle, the level-up ding, the mount poof). The live path is the boot /// name-resolve inside [`load_spell_visual_catalog`], which is what `0x61f5b0` does. @@ -389,6 +471,18 @@ impl SpellVisualCatalog { pub fn loot_art_path(&self) -> Option<&str> { self.hardcoded_effect("HARDCODED Loot Art") } + + /// A `SpellChainEffects` row by its id — the beam a kit's [`VisualKit::chain_proc`] names. + /// `None` for an id with no row, which is exactly the client's own null-row no-op and is how + /// the shipped table's padding slots resolve ([`chain_effects`]). + pub fn chain_effect(&self, id: u32) -> Option<&ChainEffect> { + self.chain_effects.get(&id) + } + + /// The number of `SpellChainEffects` rows loaded (18 on the shipped table). + pub fn chain_effect_len(&self) -> usize { + self.chain_effects.len() + } } fn n_u32_schema(name: &str, fields: usize) -> Schema { @@ -439,14 +533,20 @@ const CHAR_PROC_PARAM_FIELD: usize = CHAR_PROC_TYPE_FIELD + KIT_CHAR_PROCS; /// One kit's `CharProc` slot `i`, transposed out of the five parallel arrays. /// -/// **The empty-slot sentinel, read off the real table:** the type column uses **both** `-1` and `0` -/// for "no proc" — the same dual none-encoding this table already shows on its anim/sound FKs -/// (module docs). Every filled slot in 5875 carries a positive key, and the dispatcher's translation -/// table (`0x60dc20`) sends everything it doesn't name to the no-op case, so folding `<= 0` to -/// `None` here is behaviour-identical and keeps consumers from switching on a sentinel. +/// **The empty-slot sentinel is `-1`, and `0` is a REAL key** (corrected by decision 0955). This +/// used to fold `<= 0` to `None`, on the premise that "the dispatcher's translation table sends +/// everything it doesn't name to the no-op case". The table names `0`: reading `0x60dc20` +/// byte-for-byte, type **0 routes to case 0 (`0x60da79`) — the chain/beam case**, the same case +/// type 12 reaches. Folding it away silently discarded **34 of the 48 live beams** in the shipped +/// table, which is the whole of B161 ("Chain Lightning has no chain effect") and every missing +/// drain/channel beam with it. +/// +/// A type-`0` slot that is genuinely padding still costs nothing: its `CharParamZero` decodes to +/// `0`, which names no `SpellChainEffects` row, and both the client (`0x6ecc2e`'s null-row test) +/// and [`CharProc::as_chain`] no-op it. 20 of the 54 shipped type-0 slots are that case. fn char_proc_slot(r: &benilla_dbc::Record, i: usize) -> Option { let ty = i32_at(r, CHAR_PROC_TYPE_FIELD + i)?; - if ty <= 0 { + if ty < 0 { return None; } let mut params = [0.0; 4]; @@ -547,497 +647,16 @@ pub fn load_spell_visual_catalog(chain: &mut Chain) -> Result Vec { - let record_size = field_count * 4; - assert_eq!(records.len(), (record_count * record_size) as usize); - let mut b = Vec::new(); - b.extend_from_slice(b"WDBC"); - b.extend_from_slice(&record_count.to_le_bytes()); - b.extend_from_slice(&field_count.to_le_bytes()); - b.extend_from_slice(&record_size.to_le_bytes()); - b.extend_from_slice(&0u32.to_le_bytes()); // empty string block - b.extend_from_slice(records); - b - } - - fn u32le(v: u32) -> [u8; 4] { - v.to_le_bytes() - } - - /// One `SpellVisual` row: id, the five stage kits, then the missile block — field 7 (model - /// effect id) and field 9 (dest-attach ordinal) live, the rest zeroed to fill 16 fields. - #[allow(clippy::too_many_arguments)] - fn spell_visual_row( - id: u32, - precast: u32, - cast: u32, - impact: u32, - state: u32, - channel: u32, - missile_model: u32, - missile_attach: u32, - ) -> Vec { - let mut rec = Vec::new(); - for v in [id, precast, cast, impact, state, channel] { - rec.extend(u32le(v)); - } - for f in 6..SPELL_VISUAL_FIELDS { - rec.extend(u32le(match f { - 7 => missile_model, - 9 => missile_attach, - _ => 0, - })); - } - rec - } - - /// One `SpellVisualKit` row: id, an unpinned field 1, anim (field 2), nine zeroed emitter - /// slots (3..11), the world-effect slot (field 12), sound (field 13), then zeroed trailing - /// columns to fill 35 fields. - fn spell_visual_kit_row(id: u32, anim: u32, sound: u32, world: u32) -> Vec { - let mut rec = Vec::new(); - rec.extend(u32le(id)); - rec.extend(u32le(0)); // field 1, unpinned - rec.extend(u32le(anim)); // field 2 - for _ in 3..12 { - rec.extend(u32le(0)); // fields 3..11 (9 emitter slots) - } - rec.extend(u32le(world)); // field 12 - rec.extend(u32le(sound)); // field 13 - for _ in 14..SPELL_VISUAL_KIT_FIELDS { - rec.extend(u32le(0)); - } - rec - } - - #[test] - fn header_shape_matches_the_pinned_layout() { - // A single-row file of each still has to satisfy the pinned field/record-size shape — - // exercises the schema's own field-count check against a header lying about it would be - // a separate (missing) test; this one just guards our row builders stay in lock-step with - // SPELL_VISUAL_FIELDS/SPELL_VISUAL_KIT_FIELDS. - let sv = spell_visual_row(1, 0, 0, 0, 0, 0, 0, 0); - assert_eq!(sv.len(), SPELL_VISUAL_FIELDS * 4, "64B record"); - let svk = spell_visual_kit_row(1, 0, 0, 0); - assert_eq!(svk.len(), SPELL_VISUAL_KIT_FIELDS * 4, "140B record"); - } - - #[test] - fn parses_stage_kits_and_zero_means_no_kit_at_that_stage() { - let mut records = Vec::new(); - records.extend(spell_visual_row(67, 30, 38, 286, 0, 0, 365, 1)); // Fireball's visual row - records.extend(spell_visual_row(1, 0, 0, 0, 0, 0, 0, 0)); // an all-silent row - let bytes = build_wdbc(2, SPELL_VISUAL_FIELDS as u32, &records); - let rs = parse( - &bytes, - n_u32_schema("SpellVisual", SPELL_VISUAL_FIELDS), - "t", - ) - .unwrap(); - - let mut visuals = HashMap::new(); - for r in rs.records() { - let id = u32_at(r, 0).unwrap(); - let g = |i: usize| u32_at(r, i).unwrap_or(0); - visuals.insert( - id, - VisualStages { - precast: g(1), - cast: g(2), - impact: g(3), - state: g(4), - channel: g(5), - missile_model: g(7), - missile_attach: g(9), - missile_sound: u32_at(r, 10).and_then(some_unless_none), - strike_sound: u32_at(r, 14).and_then(some_unless_none), - missile_gate: g(6), - area_gate: g(11), - area_effect: g(12), - area_kit: g(13), - }, - ); - } - assert_eq!( - visuals[&67], - VisualStages { - precast: 30, - cast: 38, - impact: 286, - state: 0, - channel: 0, - missile_model: 365, - missile_attach: 1, - missile_sound: None, - strike_sound: None, - // The synthetic row builder writes 0 into field 6 and the dest-anchored block - // (the REAL Fireball row's missile_gate = 1 is pinned in the real-data test). - ..Default::default() - } - ); - assert_eq!( - visuals[&1], - VisualStages::default(), - "an all-zero row is all-silent" - ); - } - - #[test] - fn kit_anim_and_sound_fold_both_none_sentinels_to_none() { - let mut records = Vec::new(); - records.extend(spell_visual_kit_row(38, 53, 1484, 0)); // Fireball's cast kit - records.extend(spell_visual_kit_row(1, 0, 0, 0)); // plain-zero "none" - records.extend(spell_visual_kit_row(2, u32::MAX, u32::MAX, u32::MAX)); // the -1 "none" form - let bytes = build_wdbc(3, SPELL_VISUAL_KIT_FIELDS as u32, &records); - let rs = parse( - &bytes, - n_u32_schema("SpellVisualKit", SPELL_VISUAL_KIT_FIELDS), - "t", - ) - .unwrap(); - - let mut kits = HashMap::new(); - for r in rs.records() { - let id = u32_at(r, 0).unwrap(); - let anim_id = u32_at(r, 2).and_then(some_unless_none).map(|a| a as u16); - let sound = u32_at(r, 13).and_then(some_unless_none); - kits.insert( - id, - VisualKit { - anim_id, - sound, - ..Default::default() - }, - ); - } - assert_eq!( - kits[&38], - VisualKit { - anim_id: Some(53), - sound: Some(1484), - ..Default::default() - } - ); - assert_eq!(kits[&1], VisualKit::default(), "plain 0 = none"); - assert_eq!(kits[&2], VisualKit::default(), "0xFFFFFFFF = none too"); - } - - /// The emitter-slot surface: kit-field order maps to [`KIT_SLOT_TAGS`], both none-sentinels - /// fold out, and `effects()` yields only the populated pairs. - #[test] - fn kit_effect_slots_pair_with_their_attach_tags() { - let kit = VisualKit { - // Fireball's precast shape: LeftHand (slot index 3) + RightHand (slot index 4). - effect_slots: [ - None, - None, - None, - Some(287), - Some(287), - None, - None, - None, - None, - ], - ..Default::default() - }; - assert_eq!( - kit.effects().collect::>(), - vec![(0x15, 287), (0x16, 287)], - "LeftHand then RightHand, kit-field order" - ); - assert_eq!(VisualKit::default().effects().count(), 0); - } - - /// The tenth slot (field 12, decision 0848) rides `effects()` after the nine, at the interim - /// Base anchor — one iterator, so every kit consumer (the aura-state watcher, kit pushes, - /// cast/impact plays) picks it up without knowing it exists. - #[test] - fn kit_world_effect_joins_effects_at_base() { - let kit = VisualKit { - // Frost Nova's state-kit shape: Head sparkle (slot 0) + the feet ice in field 12. - effect_slots: [Some(54), None, None, None, None, None, None, None, None], - world_effect: Some(284), - ..Default::default() - }; - assert_eq!( - kit.effects().collect::>(), - vec![(0x14, 54), (WORLD_EFFECT_TAG, 284)], - "the nine first, then the world-plant slot" - ); - } - - /// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent) — this - /// crate's established gate (`anim_data.rs`, `spell_catalog.rs`, …). - fn vanilla_data_dir() -> std::path::PathBuf { - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") - } - - /// End-to-end on the real build-5875 tables: the byte-verified header shape (2165×16/64B · - /// 1772×35/140B) and the full Fireball chain (module doc's "Verified chain") — a schema drift - /// or column slip fails loudly. Skips without client data. - #[test] - fn real_spell_visual_chain_resolves_fireball() { - 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 cat = load_spell_visual_catalog(&mut chain).expect("load SpellVisual/SpellVisualKit"); - assert_eq!(cat.len(), 2165, "all 5875 SpellVisual rows load"); - assert_eq!(cat.kit_len(), 1772, "all 5875 SpellVisualKit rows load"); - - // Fireball (spell 133) → visual 67 (spells.rs's column-115 pin). - let stages = cat.stages(67).expect("Fireball's SpellVisual row"); - assert_eq!( - *stages, - VisualStages { - precast: 30, - cast: 38, - impact: 286, - state: 0, - channel: 0, - missile_model: 365, - missile_attach: 1, - // Field 10: the fireball's in-flight loop (SoundEntries 3011 → FireMissileLoop.wav). - missile_sound: Some(3011), - strike_sound: None, - // Field 6 set = a missile owns the arrival: the GO dest one-shot gate is closed - // (0797); the dest-anchored columns are empty on a projectile nuke. - missile_gate: 1, - area_gate: 0, - area_effect: 0, - area_kit: 0, - } - ); - // The gathering/work strike sounds (decision 0562): Mining's visual 93 carries the pick - // clang in field 14 (SoundEntries 1143 "Mining Impact" = MiningHitA-E), Herb's 91 the - // search rustle (1142) - the $TRD anim event's operands. - assert_eq!( - cat.stages(93).and_then(|s| s.strike_sound), - Some(1143), - "Mining's visual carries the field-14 strike clang" - ); - assert_eq!( - cat.stages(91).and_then(|s| s.strike_sound), - Some(1142), - "Herb Gathering's visual carries the field-14 rustle" - ); - - // A basic thrown attack borrows the equipped weapon's substitute visual (98 — the thrown - // dagger's ItemDisplayInfo col 10): no missile MODEL (it flies the weapon itself) but a - // flight loop in field 10 (SoundEntries 3318 → WeaponLoop.wav) — the whoosh the projectile - // carries while it travels. - let thrown = cat - .stages(98) - .expect("the thrown-weapon substitute SpellVisual"); - assert_eq!( - thrown.missile_model, 0, - "a thrown weapon flies its own model, not an effect" - ); - assert_eq!( - thrown.missile_sound, - Some(3318), - "the thrown weapon's flight loop" - ); - // The missile chain (phase 4): field 7 → the projectile's own model, field 9 → the - // chest attach the missile homes to (ordinal 1 → 0x22). - assert_eq!( - cat.effect_path(stages.missile_model), - Some("Spells\\Fireball_Missile_Low.mdx"), - "the flying fireball's model" - ); - assert_eq!( - MISSILE_ATTACH_TABLE[stages.missile_attach as usize], 0x22, - "Fireball homes onto the target's chest" - ); - - // The engine-spawned hardcoded set resolves by the client's own baked names — the ding - // (row 21, byte-verified `0x61f5b0`/`0x8618e0`, decision 0304's §5 fold-back). - assert_eq!( - cat.hardcoded_effect("HARDCODED Unit Level Up"), - Some("Spells\\LevelUp\\LevelUp.mdl"), - "the level-up pillar resolves by name" - ); - assert!( - cat.hardcoded_effect("HARDCODED Loot Art").is_some(), - "the sibling hardcoded rows ride the same map" - ); - - let cast_kit = cat.kit(stages.cast).expect("cast kit"); - assert_eq!( - cast_kit.anim_id, - Some(53), - "AnimationData id 53 = SpellCastDirected" - ); - assert_eq!(cast_kit.sound, Some(1484)); - - let impact_kit = cat.kit(stages.impact).expect("impact kit"); - assert_eq!( - impact_kit.anim_id, - Some(9), - "AnimationData id 9 = CombatWound, the target's hit reaction" - ); - assert_eq!(impact_kit.sound, Some(1507)); - - // The precast kit's emitter slots (phase 3): effect 287 on both hands, and its - // SpellVisualEffectName path — the glowing-hands chain end to end. - let precast_kit = cat.kit(stages.precast).expect("precast kit"); - assert_eq!( - precast_kit.effects().collect::>(), - vec![(0x15, 287), (0x16, 287)], - "Fire_Precast_Hand on LeftHand + RightHand" - ); - assert_eq!( - cat.effect_path(287), - Some("Spells\\Fire_Precast_Hand.mdx"), - "SpellVisualEffectName field 2 = the effect model path" - ); - - // The tenth slot (field 12, decision 0848) on the real table — the root/snare state - // family the nine slots miss. Frost Nova (spell 122 → visual 17): state kit 285's feet - // ice; Net (spell 6533 → visual 683): state kit 744's net wrap, a kit with NO ordinary - // slots at all. - let frost_nova_state = cat - .kit(cat.stages(17).expect("Frost Nova's visual").state) - .expect("Frost Nova's state kit"); - assert_eq!(frost_nova_state.world_effect, Some(284)); - assert_eq!( - cat.effect_path(284), - Some("Spells\\Frost_Nova_state.mdx"), - "the frozen-feet model rides field 12" - ); - let net_state = cat - .kit(cat.stages(683).expect("Net's visual").state) - .expect("Net's state kit"); - assert_eq!( - net_state.effects().collect::>(), - vec![(WORLD_EFFECT_TAG, 594)], - "the net wrap is the kit's ONLY effect — invisible without field 12" - ); - assert_eq!(cat.effect_path(594), Some("Spells\\Net_State.mdx")); - // The impact kit's chest burst — the phase-4 arrival hand-off will play this. - assert_eq!( - cat.kit(stages.impact) - .unwrap() - .effects() - .collect::>(), - vec![(0x22, 321)], - "MoltenBlast_Impact_Chest on the Chest attach" - ); - } - - /// The real 5875 `SpellVisualEffectName`: the boot-time HARDCODED name matcher's one consumed - /// row — `"HARDCODED Loot Art"` is id 14 → `Particles\LootFX.mdl` (values pre-checked against - /// the raw DBC bytes), and the `.m2` it names ships in the chain, so the lootable-corpse - /// sparkle can actually load (wow-re `loot-corpse-effect.md`). Skips without client data. - #[test] - fn real_effect_name_table_resolves_the_loot_art_row() { - 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 cat = load_spell_visual_catalog(&mut chain).expect("load the visual catalog"); - assert_eq!(cat.loot_art_path(), Some("Particles\\LootFX.mdl")); - // The model the row names ships in the chain (consumers rewrite .mdl → .m2 to load it). - assert!( - chain.read_file("Particles\\LootFX.m2").is_ok(), - "LootFX.m2 must ship in the chain" - ); - } -} - -#[cfg(test)] -mod ground_fx_data_tests { - use super::*; - - /// The dest-anchored chain on the REAL data — 0797's mandatory per-spell data check (the - /// wow-re read pinned the GATE; whether each spell passes it is a table fact). Skips - /// without client data. Every value here corroborates the live vmangos wire capture: - /// Blizzard's dynobj RADIUS was 8.0 (row 14), Flamestrike's 5.0 (row 8). - #[test] - fn ground_aoe_chain_reads_the_dest_anchored_block() { - 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 catalog = crate::load_spell_catalog(&mut chain).expect("spells"); - let visuals = load_spell_visual_catalog(&mut chain).expect("visuals"); - let radii = crate::load_spell_radii(&mut chain).expect("radii"); - - // Blizzard 10 → visual 259: both visuals — its own model AND a type-9 shard emitter - // whose table index decodes to 0 (the same model), rate 5/s, sound 7. - let d = catalog.get(10).unwrap(); - assert_eq!((d.visual, d.effect_radius_index), (259, [14, 0, 0])); - assert_eq!( - radii.get(14).map(|r| r.radius), - Some(8.0), - "the wire radius" - ); - let v = visuals.stages(259).unwrap(); - assert_eq!( - (v.missile_gate, v.area_gate, v.area_effect, v.area_kit), - (0, 1, 398, 609) - ); - assert_eq!( - visuals.effect_path(398), - Some("Spells\\Blizzard_Impact_Base.mdx") - ); - let k = visuals.kit(609).unwrap(); - assert_eq!(k.sound, Some(7)); - let proc = k.char_procs().find(|p| p.ty == 9).unwrap(); - assert_eq!((proc.params[0], proc.params[1]), (0.0, 5.0)); - - // Flamestrike 2120 → visual 33: its own model + sound, NO type-9 proc (a burning - // patch has no falling shards) — the emitter half is data-absent, not code-gated. - let d = catalog.get(2120).unwrap(); - assert_eq!((d.visual, d.effect_radius_index), (33, [8, 8, 0])); - assert_eq!(radii.get(8).map(|r| r.radius), Some(5.0), "the wire radius"); - let v = visuals.stages(33).unwrap(); - assert_eq!( - (v.missile_gate, v.area_gate, v.area_effect, v.area_kit), - (0, 1, 420, 695) - ); - assert_eq!( - visuals.effect_path(420), - Some("Spells\\Flamestrike_Impact_Base.mdx") - ); - let k = visuals.kit(695).unwrap(); - assert_eq!(k.sound, Some(3077)); - assert!(k.char_procs().all(|p| p.ty != 9)); - - // Rain of Fire 5740 → visual 329: shard-table index 1 (its own model again), rate 5/s. - let v = visuals.stages(catalog.get(5740).unwrap().visual).unwrap(); - assert_eq!((v.missile_gate, v.area_gate, v.area_effect), (0, 1, 448)); - let proc = visuals - .kit(v.area_kit) - .unwrap() - .char_procs() - .find(|p| p.ty == 9) - .unwrap(); - assert_eq!((proc.params[0], proc.params[1]), (1.0, 5.0)); - - // All three pass the GO dest one-shot gate (field 6 == 0 ∧ field 12 ≠ 0) — the burst - // fires for each; Fireball (missile_gate 1, pinned in the loader test above) does not. - } -} +mod tests; diff --git a/crates/benilla-formats/src/spell_visual/tests.rs b/crates/benilla-formats/src/spell_visual/tests.rs new file mode 100644 index 00000000..1da2981d --- /dev/null +++ b/crates/benilla-formats/src/spell_visual/tests.rs @@ -0,0 +1,671 @@ +//! Tests for the spell-visual DBC family — the tables `super` loads, exercised against +//! synthetic WDBCs and against the real 5875 client data (those skip when it is absent). +//! Lifted out of `mod.rs` whole when that file outgrew its budget; two `#[cfg(test)]` +//! modules merged into this one file scope. + +use super::*; + +/// A minimal synthetic WDBC (20-byte header + fixed-width u32 records, no strings — both +/// tables are all-`u32`) — the same shape `benilla-dbc`'s own tests build (`items.rs` +/// reproduces it too), so this adapter is testable without a real client install. +fn build_wdbc(record_count: u32, field_count: u32, records: &[u8]) -> Vec { + let record_size = field_count * 4; + assert_eq!(records.len(), (record_count * record_size) as usize); + let mut b = Vec::new(); + b.extend_from_slice(b"WDBC"); + b.extend_from_slice(&record_count.to_le_bytes()); + b.extend_from_slice(&field_count.to_le_bytes()); + b.extend_from_slice(&record_size.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); // empty string block + b.extend_from_slice(records); + b +} + +fn u32le(v: u32) -> [u8; 4] { + v.to_le_bytes() +} + +/// One `SpellVisual` row: id, the five stage kits, then the missile block — field 7 (model +/// effect id) and field 9 (dest-attach ordinal) live, the rest zeroed to fill 16 fields. +#[allow(clippy::too_many_arguments)] +fn spell_visual_row( + id: u32, + precast: u32, + cast: u32, + impact: u32, + state: u32, + channel: u32, + missile_model: u32, + missile_attach: u32, +) -> Vec { + let mut rec = Vec::new(); + for v in [id, precast, cast, impact, state, channel] { + rec.extend(u32le(v)); + } + for f in 6..SPELL_VISUAL_FIELDS { + rec.extend(u32le(match f { + 7 => missile_model, + 9 => missile_attach, + _ => 0, + })); + } + rec +} + +/// One `SpellVisualKit` row: id, an unpinned field 1, anim (field 2), nine zeroed emitter +/// slots (3..11), the world-effect slot (field 12), sound (field 13), then zeroed trailing +/// columns to fill 35 fields. +fn spell_visual_kit_row(id: u32, anim: u32, sound: u32, world: u32) -> Vec { + let mut rec = Vec::new(); + rec.extend(u32le(id)); + rec.extend(u32le(0)); // field 1, unpinned + rec.extend(u32le(anim)); // field 2 + for _ in 3..12 { + rec.extend(u32le(0)); // fields 3..11 (9 emitter slots) + } + rec.extend(u32le(world)); // field 12 + rec.extend(u32le(sound)); // field 13 + for _ in 14..SPELL_VISUAL_KIT_FIELDS { + rec.extend(u32le(0)); + } + rec +} + +#[test] +fn header_shape_matches_the_pinned_layout() { + // A single-row file of each still has to satisfy the pinned field/record-size shape — + // exercises the schema's own field-count check against a header lying about it would be + // a separate (missing) test; this one just guards our row builders stay in lock-step with + // SPELL_VISUAL_FIELDS/SPELL_VISUAL_KIT_FIELDS. + let sv = spell_visual_row(1, 0, 0, 0, 0, 0, 0, 0); + assert_eq!(sv.len(), SPELL_VISUAL_FIELDS * 4, "64B record"); + let svk = spell_visual_kit_row(1, 0, 0, 0); + assert_eq!(svk.len(), SPELL_VISUAL_KIT_FIELDS * 4, "140B record"); +} + +#[test] +fn parses_stage_kits_and_zero_means_no_kit_at_that_stage() { + let mut records = Vec::new(); + records.extend(spell_visual_row(67, 30, 38, 286, 0, 0, 365, 1)); // Fireball's visual row + records.extend(spell_visual_row(1, 0, 0, 0, 0, 0, 0, 0)); // an all-silent row + let bytes = build_wdbc(2, SPELL_VISUAL_FIELDS as u32, &records); + let rs = parse( + &bytes, + n_u32_schema("SpellVisual", SPELL_VISUAL_FIELDS), + "t", + ) + .unwrap(); + + let mut visuals = HashMap::new(); + for r in rs.records() { + let id = u32_at(r, 0).unwrap(); + let g = |i: usize| u32_at(r, i).unwrap_or(0); + visuals.insert( + id, + VisualStages { + precast: g(1), + cast: g(2), + impact: g(3), + state: g(4), + channel: g(5), + missile_model: g(7), + missile_attach: g(9), + missile_sound: u32_at(r, 10).and_then(some_unless_none), + strike_sound: u32_at(r, 14).and_then(some_unless_none), + missile_gate: g(6), + area_gate: g(11), + area_effect: g(12), + area_kit: g(13), + }, + ); + } + assert_eq!( + visuals[&67], + VisualStages { + precast: 30, + cast: 38, + impact: 286, + state: 0, + channel: 0, + missile_model: 365, + missile_attach: 1, + missile_sound: None, + strike_sound: None, + // The synthetic row builder writes 0 into field 6 and the dest-anchored block + // (the REAL Fireball row's missile_gate = 1 is pinned in the real-data test). + ..Default::default() + } + ); + assert_eq!( + visuals[&1], + VisualStages::default(), + "an all-zero row is all-silent" + ); +} + +#[test] +fn kit_anim_and_sound_fold_both_none_sentinels_to_none() { + let mut records = Vec::new(); + records.extend(spell_visual_kit_row(38, 53, 1484, 0)); // Fireball's cast kit + records.extend(spell_visual_kit_row(1, 0, 0, 0)); // plain-zero "none" + records.extend(spell_visual_kit_row(2, u32::MAX, u32::MAX, u32::MAX)); // the -1 "none" form + let bytes = build_wdbc(3, SPELL_VISUAL_KIT_FIELDS as u32, &records); + let rs = parse( + &bytes, + n_u32_schema("SpellVisualKit", SPELL_VISUAL_KIT_FIELDS), + "t", + ) + .unwrap(); + + let mut kits = HashMap::new(); + for r in rs.records() { + let id = u32_at(r, 0).unwrap(); + let anim_id = u32_at(r, 2).and_then(some_unless_none).map(|a| a as u16); + let sound = u32_at(r, 13).and_then(some_unless_none); + kits.insert( + id, + VisualKit { + anim_id, + sound, + ..Default::default() + }, + ); + } + assert_eq!( + kits[&38], + VisualKit { + anim_id: Some(53), + sound: Some(1484), + ..Default::default() + } + ); + assert_eq!(kits[&1], VisualKit::default(), "plain 0 = none"); + assert_eq!(kits[&2], VisualKit::default(), "0xFFFFFFFF = none too"); +} + +/// The emitter-slot surface: kit-field order maps to [`KIT_SLOT_TAGS`], both none-sentinels +/// fold out, and `effects()` yields only the populated pairs. +#[test] +fn kit_effect_slots_pair_with_their_attach_tags() { + let kit = VisualKit { + // Fireball's precast shape: LeftHand (slot index 3) + RightHand (slot index 4). + effect_slots: [ + None, + None, + None, + Some(287), + Some(287), + None, + None, + None, + None, + ], + ..Default::default() + }; + assert_eq!( + kit.effects().collect::>(), + vec![(0x15, 287), (0x16, 287)], + "LeftHand then RightHand, kit-field order" + ); + assert_eq!(VisualKit::default().effects().count(), 0); +} + +/// The tenth slot (field 12, decision 0848) rides `effects()` after the nine, at the interim +/// Base anchor — one iterator, so every kit consumer (the aura-state watcher, kit pushes, +/// cast/impact plays) picks it up without knowing it exists. +#[test] +fn kit_world_effect_joins_effects_at_base() { + let kit = VisualKit { + // Frost Nova's state-kit shape: Head sparkle (slot 0) + the feet ice in field 12. + effect_slots: [Some(54), None, None, None, None, None, None, None, None], + world_effect: Some(284), + ..Default::default() + }; + assert_eq!( + kit.effects().collect::>(), + vec![(0x14, 54), (WORLD_EFFECT_TAG, 284)], + "the nine first, then the world-plant slot" + ); +} + +/// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent) — this +/// crate's established gate (`anim_data.rs`, `spell_catalog.rs`, …). +fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +/// End-to-end on the real build-5875 tables: the byte-verified header shape (2165×16/64B · +/// 1772×35/140B) and the full Fireball chain (module doc's "Verified chain") — a schema drift +/// or column slip fails loudly. Skips without client data. +#[test] +fn real_spell_visual_chain_resolves_fireball() { + 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 cat = load_spell_visual_catalog(&mut chain).expect("load SpellVisual/SpellVisualKit"); + assert_eq!(cat.len(), 2165, "all 5875 SpellVisual rows load"); + assert_eq!(cat.kit_len(), 1772, "all 5875 SpellVisualKit rows load"); + + // Fireball (spell 133) → visual 67 (spells.rs's column-115 pin). + let stages = cat.stages(67).expect("Fireball's SpellVisual row"); + assert_eq!( + *stages, + VisualStages { + precast: 30, + cast: 38, + impact: 286, + state: 0, + channel: 0, + missile_model: 365, + missile_attach: 1, + // Field 10: the fireball's in-flight loop (SoundEntries 3011 → FireMissileLoop.wav). + missile_sound: Some(3011), + strike_sound: None, + // Field 6 set = a missile owns the arrival: the GO dest one-shot gate is closed + // (0797); the dest-anchored columns are empty on a projectile nuke. + missile_gate: 1, + area_gate: 0, + area_effect: 0, + area_kit: 0, + } + ); + // The gathering/work strike sounds (decision 0562): Mining's visual 93 carries the pick + // clang in field 14 (SoundEntries 1143 "Mining Impact" = MiningHitA-E), Herb's 91 the + // search rustle (1142) - the $TRD anim event's operands. + assert_eq!( + cat.stages(93).and_then(|s| s.strike_sound), + Some(1143), + "Mining's visual carries the field-14 strike clang" + ); + assert_eq!( + cat.stages(91).and_then(|s| s.strike_sound), + Some(1142), + "Herb Gathering's visual carries the field-14 rustle" + ); + + // A basic thrown attack borrows the equipped weapon's substitute visual (98 — the thrown + // dagger's ItemDisplayInfo col 10): no missile MODEL (it flies the weapon itself) but a + // flight loop in field 10 (SoundEntries 3318 → WeaponLoop.wav) — the whoosh the projectile + // carries while it travels. + let thrown = cat + .stages(98) + .expect("the thrown-weapon substitute SpellVisual"); + assert_eq!( + thrown.missile_model, 0, + "a thrown weapon flies its own model, not an effect" + ); + assert_eq!( + thrown.missile_sound, + Some(3318), + "the thrown weapon's flight loop" + ); + // The missile chain (phase 4): field 7 → the projectile's own model, field 9 → the + // chest attach the missile homes to (ordinal 1 → 0x22). + assert_eq!( + cat.effect_path(stages.missile_model), + Some("Spells\\Fireball_Missile_Low.mdx"), + "the flying fireball's model" + ); + assert_eq!( + MISSILE_ATTACH_TABLE[stages.missile_attach as usize], 0x22, + "Fireball homes onto the target's chest" + ); + + // The engine-spawned hardcoded set resolves by the client's own baked names — the ding + // (row 21, byte-verified `0x61f5b0`/`0x8618e0`, decision 0304's §5 fold-back). + assert_eq!( + cat.hardcoded_effect("HARDCODED Unit Level Up"), + Some("Spells\\LevelUp\\LevelUp.mdl"), + "the level-up pillar resolves by name" + ); + assert!( + cat.hardcoded_effect("HARDCODED Loot Art").is_some(), + "the sibling hardcoded rows ride the same map" + ); + + let cast_kit = cat.kit(stages.cast).expect("cast kit"); + assert_eq!( + cast_kit.anim_id, + Some(53), + "AnimationData id 53 = SpellCastDirected" + ); + assert_eq!(cast_kit.sound, Some(1484)); + + let impact_kit = cat.kit(stages.impact).expect("impact kit"); + assert_eq!( + impact_kit.anim_id, + Some(9), + "AnimationData id 9 = CombatWound, the target's hit reaction" + ); + assert_eq!(impact_kit.sound, Some(1507)); + + // The precast kit's emitter slots (phase 3): effect 287 on both hands, and its + // SpellVisualEffectName path — the glowing-hands chain end to end. + let precast_kit = cat.kit(stages.precast).expect("precast kit"); + assert_eq!( + precast_kit.effects().collect::>(), + vec![(0x15, 287), (0x16, 287)], + "Fire_Precast_Hand on LeftHand + RightHand" + ); + assert_eq!( + cat.effect_path(287), + Some("Spells\\Fire_Precast_Hand.mdx"), + "SpellVisualEffectName field 2 = the effect model path" + ); + + // The tenth slot (field 12, decision 0848) on the real table — the root/snare state + // family the nine slots miss. Frost Nova (spell 122 → visual 17): state kit 285's feet + // ice; Net (spell 6533 → visual 683): state kit 744's net wrap, a kit with NO ordinary + // slots at all. + let frost_nova_state = cat + .kit(cat.stages(17).expect("Frost Nova's visual").state) + .expect("Frost Nova's state kit"); + assert_eq!(frost_nova_state.world_effect, Some(284)); + assert_eq!( + cat.effect_path(284), + Some("Spells\\Frost_Nova_state.mdx"), + "the frozen-feet model rides field 12" + ); + let net_state = cat + .kit(cat.stages(683).expect("Net's visual").state) + .expect("Net's state kit"); + assert_eq!( + net_state.effects().collect::>(), + vec![(WORLD_EFFECT_TAG, 594)], + "the net wrap is the kit's ONLY effect — invisible without field 12" + ); + assert_eq!(cat.effect_path(594), Some("Spells\\Net_State.mdx")); + // The impact kit's chest burst — the phase-4 arrival hand-off will play this. + assert_eq!( + cat.kit(stages.impact) + .unwrap() + .effects() + .collect::>(), + vec![(0x22, 321)], + "MoltenBlast_Impact_Chest on the Chest attach" + ); +} + +/// The real 5875 `SpellVisualEffectName`: the boot-time HARDCODED name matcher's one consumed +/// row — `"HARDCODED Loot Art"` is id 14 → `Particles\LootFX.mdl` (values pre-checked against +/// the raw DBC bytes), and the `.m2` it names ships in the chain, so the lootable-corpse +/// sparkle can actually load (wow-re `loot-corpse-effect.md`). Skips without client data. +#[test] +fn real_effect_name_table_resolves_the_loot_art_row() { + 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 cat = load_spell_visual_catalog(&mut chain).expect("load the visual catalog"); + assert_eq!(cat.loot_art_path(), Some("Particles\\LootFX.mdl")); + // The model the row names ships in the chain (consumers rewrite .mdl → .m2 to load it). + assert!( + chain.read_file("Particles\\LootFX.m2").is_ok(), + "LootFX.m2 must ship in the chain" + ); +} + +/// The dest-anchored chain on the REAL data — 0797's mandatory per-spell data check (the +/// wow-re read pinned the GATE; whether each spell passes it is a table fact). Skips +/// without client data. Every value here corroborates the live vmangos wire capture: +/// Blizzard's dynobj RADIUS was 8.0 (row 14), Flamestrike's 5.0 (row 8). +#[test] +fn ground_aoe_chain_reads_the_dest_anchored_block() { + 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 catalog = crate::load_spell_catalog(&mut chain).expect("spells"); + let visuals = load_spell_visual_catalog(&mut chain).expect("visuals"); + let radii = crate::load_spell_radii(&mut chain).expect("radii"); + + // Blizzard 10 → visual 259: both visuals — its own model AND a type-9 shard emitter + // whose table index decodes to 0 (the same model), rate 5/s, sound 7. + let d = catalog.get(10).unwrap(); + assert_eq!((d.visual, d.effect_radius_index), (259, [14, 0, 0])); + assert_eq!( + radii.get(14).map(|r| r.radius), + Some(8.0), + "the wire radius" + ); + let v = visuals.stages(259).unwrap(); + assert_eq!( + (v.missile_gate, v.area_gate, v.area_effect, v.area_kit), + (0, 1, 398, 609) + ); + assert_eq!( + visuals.effect_path(398), + Some("Spells\\Blizzard_Impact_Base.mdx") + ); + let k = visuals.kit(609).unwrap(); + assert_eq!(k.sound, Some(7)); + let proc = k.char_procs().find(|p| p.ty == 9).unwrap(); + assert_eq!((proc.params[0], proc.params[1]), (0.0, 5.0)); + + // Flamestrike 2120 → visual 33: its own model + sound, NO type-9 proc (a burning + // patch has no falling shards) — the emitter half is data-absent, not code-gated. + let d = catalog.get(2120).unwrap(); + assert_eq!((d.visual, d.effect_radius_index), (33, [8, 8, 0])); + assert_eq!(radii.get(8).map(|r| r.radius), Some(5.0), "the wire radius"); + let v = visuals.stages(33).unwrap(); + assert_eq!( + (v.missile_gate, v.area_gate, v.area_effect, v.area_kit), + (0, 1, 420, 695) + ); + assert_eq!( + visuals.effect_path(420), + Some("Spells\\Flamestrike_Impact_Base.mdx") + ); + let k = visuals.kit(695).unwrap(); + assert_eq!(k.sound, Some(3077)); + assert!(k.char_procs().all(|p| p.ty != 9)); + + // Rain of Fire 5740 → visual 329: shard-table index 1 (its own model again), rate 5/s. + let v = visuals.stages(catalog.get(5740).unwrap().visual).unwrap(); + assert_eq!((v.missile_gate, v.area_gate, v.area_effect), (0, 1, 448)); + let proc = visuals + .kit(v.area_kit) + .unwrap() + .char_procs() + .find(|p| p.ty == 9) + .unwrap(); + assert_eq!((proc.params[0], proc.params[1]), (1.0, 5.0)); + + // All three pass the GO dest one-shot gate (field 6 == 0 ∧ field 12 ≠ 0) — the burst + // fires for each; Fireball (missile_gate 1, pinned in the loader test above) does not. +} + +// ── The chain/beam table (decision 0955) ────────────────────────────────────────────────────── + +/// The small-int decode, on the exact values the shipped table ships. This is the load-bearing +/// arithmetic — get it wrong and every beam resolves to the wrong texture (or to none) — so it is +/// pinned against hand-computed IEEE bits rather than against itself. +#[test] +fn char_proc_small_int_recovers_the_integer() { + // 1.0 + 512.0 = 513.0 = 0x44004000; >>14 = 0x11001; &0xff = 1. + assert_eq!(char_proc_small_int(1.0), 1); + assert_eq!(char_proc_small_int(0.0), 0); + for n in 0..=255u32 { + assert_eq!(char_proc_small_int(n as f32), n, "round-trips every byte"); + } + // The decode truncates rather than rounds — it reads the integer part out of the mantissa. + assert_eq!(char_proc_small_int(3.9), 3); +} + +/// `SpellChainEffects.dbc` as it actually ships: 18 rows, the gaps at 14/16, and the two rows the +/// director's report turns on — id 1 (Chain Lightning's `Lightning`, the only row with a non-zero +/// segment delay) and id 8 (Drain Life's `SoulBeam`, one of the four negative-texcoord drains). +#[test] +fn real_chain_effects_table() { + 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 cat = load_spell_visual_catalog(&mut chain).expect("load spell visuals"); + assert_eq!( + cat.chain_effect_len(), + 18, + "18 SpellChainEffects rows in 5875" + ); + + let lightning = cat.chain_effect(1).expect("chain effect 1"); + assert_eq!( + lightning.texture, + "Textures\\SpellChainEffects\\Lightning.blp" + ); + assert_eq!(lightning.avg_seg_len, 2.78); + assert_eq!(lightning.half_width, 0.5); + assert_eq!(lightning.noise_scale, 0.04); + assert_eq!(lightning.scroll_period_s, 1.0); + assert_eq!(lightning.seg_duration_ms, 1000); + assert_eq!( + lightning.seg_delay_ms, 300, + "id 1 is the one row at 300 ms — every other live row is 200" + ); + + assert_eq!( + cat.chain_effect(4).expect("chain effect 4").seg_delay_ms, + 200, + "…and 200 is the table's norm" + ); + + let drain = cat.chain_effect(8).expect("chain effect 8"); + assert_eq!(drain.texture, "Textures\\SpellChainEffects\\SoulBeam.blp"); + assert_eq!( + drain.scroll_period_s, -0.5, + "the drains ship a NEGATIVE scroll period — the texture flows back toward the caster" + ); + assert!( + drain.noise_scale < lightning.noise_scale, + "a drain rope is far straighter than a lightning arc" + ); + + // Ids are a lookup, never an index: 14 and 16 are absent from an otherwise 1..=20 run. + assert!(cat.chain_effect(14).is_none() && cat.chain_effect(16).is_none()); + assert!(cat.chain_effect(0).is_none(), "id 0 is the client's no-op"); + assert!(cat.chain_effect(21).is_none()); +} + +/// **The regression that IS decision 0955.** The chain `CharProc` reaches its beam on the real +/// table, for both keys — and every live chain slot resolves to a real row. +/// +/// The old `char_proc_slot` folded type `0` to "empty", so 34 of the 48 live beams (every channel +/// beam in the game) never existed as far as this crate was concerned. A regression here reads as +/// "Drain Life has no beam again". +#[test] +fn real_chain_procs_resolve_to_their_beams() { + 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 cat = load_spell_visual_catalog(&mut chain).expect("load spell visuals"); + + // Chain Lightning (spell 421 → visual 36) — cast kit 321, type 12, one beam, flag clear. + let cl = cat.kit(321).expect("kit 321").chain_proc().expect("a beam"); + assert_eq!(cl.ty, char_proc_type::CHAIN_CAST); + assert_eq!((cl.effect_id, cl.beams, cl.flag), (1, 1, false)); + assert_eq!( + cat.chain_effect(cl.effect_id).map(|c| c.texture.as_str()), + Some("Textures\\SpellChainEffects\\Lightning.blp") + ); + + // Drain Life (spell 689 → visual 177) — channel kit 402, type 0, flag SET. This is the one + // the `<= 0` sentinel used to swallow whole. + let dl = cat.kit(402).expect("kit 402").chain_proc().expect("a beam"); + assert_eq!(dl.ty, char_proc_type::CHAIN_CHANNEL); + assert_eq!((dl.effect_id, dl.beams, dl.flag), (8, 1, true)); + + // The named cast-stage others, each predicting its own texture — the corroboration that the + // decode is the mechanism and not a coincidence that fits Chain Lightning. + for (kit, effect, texture) in [ + (3169u32, 2u32, "HealBeam.blp"), // Chain Heal + (430, 4, "ManaBeam.blp"), // Drain Mana / Mind Flay (channel) + (950, 10, "Beam_Purple.blp"), // Drain Soul (channel) + (342, 5, "SoulBeam.blp"), // Health Funnel (channel) + (2509, 6, "ManaBurnBeam.blp"), // Shrink Ray + (6480, 18, "SoulBeam.blp"), // C'Thun's Eye Beam + (6567, 7, "ShockLightning.blp"), // the Feugen/Stalagg chains + (6397, 3, "DrainManaLightning.blp"), // Chain Burn — the one 3-beam kit + ] { + let p = cat + .kit(kit) + .unwrap_or_else(|| panic!("kit {kit}")) + .chain_proc() + .unwrap_or_else(|| panic!("kit {kit} draws a beam")); + assert_eq!(p.effect_id, effect, "kit {kit}"); + let got = cat + .chain_effect(p.effect_id) + .expect("its row") + .texture + .clone(); + assert!( + got.ends_with(texture), + "kit {kit}: {got} should end {texture}" + ); + } + assert_eq!( + cat.kit(6397).unwrap().chain_proc().unwrap().beams, + 3, + "Chain Burn is the only kit asking for more than one beam" + ); + + // The whole-table census — the shape the census instrument prints, asserted so a schema slip + // or a sentinel regression is a number that moved. + let (mut slots, mut live, mut padding) = (0, 0, 0); + for id in cat.kit_ids() { + let kit = cat.kit(id).expect("kit"); + for proc in kit.char_procs().filter(|p| char_proc_type::is_chain(p.ty)) { + slots += 1; + match proc.as_chain() { + Some(c) => { + live += 1; + assert!( + cat.chain_effect(c.effect_id).is_some(), + "kit {id} decodes to chain id {} — every live slot must name a real row", + c.effect_id + ); + } + None => padding += 1, + } + } + } + assert_eq!( + (slots, live, padding), + (68, 48, 20), + "68 chain CharProc slots ship: 48 live beams, 20 zero-param padding" + ); +} + +/// The padding case, isolated: a type-0 slot whose `CharParamZero` is `0` is not a beam — the +/// client's own null-row test no-ops it, and so must we. Kit 2089 (Death & Decay, Ritual of Doom) +/// ships four of them. +#[test] +fn real_zero_param_chain_slots_are_padding() { + 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 cat = load_spell_visual_catalog(&mut chain).expect("load spell visuals"); + let kit = cat.kit(2089).expect("kit 2089"); + assert_eq!( + kit.char_procs() + .filter(|p| p.ty == char_proc_type::CHAIN_CHANNEL) + .count(), + 4, + "four type-0 slots — they survive the parser now" + ); + assert!(kit.chain_proc().is_none(), "…but none of them is a beam"); +} diff --git a/crates/benilla-formats/tests/chain.rs b/crates/benilla-formats/tests/chain.rs index ab8bdbd5..fe8120ae 100644 --- a/crates/benilla-formats/tests/chain.rs +++ b/crates/benilla-formats/tests/chain.rs @@ -354,6 +354,52 @@ fn terrain_chunks_carry_unit_upward_mcnr_normals() { ); } +/// Every terrain triangle must wind **CCW seen from above** in WoW space — the invariant that lets +/// the renderer draw terrain single-sided (backface-culled), which is what the real 1.12.1 client +/// does: its terrain-chunk pass never touches `EGxRs 0x14`, so it inherits the device baseline +/// `CULL_FACE = 1`, and the ground is see-through from underneath. Flip a fan's winding and the +/// world would vanish when viewed from *above* instead — a catastrophic, silent regression that no +/// other test here would catch, since winding changes nothing about positions, seams, or heights. +/// +/// The check is exact rather than statistical: terrain is a heightfield on a fixed XY lattice, so +/// the sign of a triangle's geometric normal Z is the sign of its XY-projected signed area, which +/// is independent of the authored heights. Every fan should give the same positive value. +#[test] +fn terrain_fans_wind_ccw_seen_from_above() { + 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 (tx, ty) = benilla_formats::find_tile_near(&mut chain, "Azeroth", -8840.56, 489.7) + .expect("find an Elwynn tile"); + let tile = + benilla_formats::load_tile_mesh(&mut chain, "Azeroth", tx, ty).expect("mesh the tile"); + + let (mut tris, mut min_nz) = (0usize, f32::MAX); + for chunk in &tile.chunks { + for t in chunk.indices.chunks_exact(3) { + let (a, b, c) = ( + chunk.positions[t[0] as usize], + chunk.positions[t[1] as usize], + chunk.positions[t[2] as usize], + ); + // Z of (b−a)×(c−a): positive ⇔ CCW viewed down the +Z (up) axis. + let nz = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + min_nz = min_nz.min(nz); + tris += 1; + } + } + assert!(tris > 1000, "expected a meshed tile, got {tris} triangles"); + assert!( + min_nz > 0.0, + "every terrain triangle must face up (CCW from above in WoW space); \ + worst geometric-normal Z was {min_nz} over {tris} triangles" + ); +} + #[test] fn terrain_is_watertight_at_chunk_seams() { let data = vanilla_data_dir(); diff --git a/crates/benilla-formats/tests/wmo_doorway_fade.rs b/crates/benilla-formats/tests/wmo_doorway_fade.rs new file mode 100644 index 00000000..9e2e825b --- /dev/null +++ b/crates/benilla-formats/tests/wmo_doorway_fade.rs @@ -0,0 +1,120 @@ +//! **FixColorVertexAlpha** — the bright-doorway portal fade, pinned to the reference's own live +//! capture (wow-re `trace-forensics-abbey-interior-d3d.md` §2) and to the building the fade is +//! load-bearing for (Dire Maul's entrance corridors). +//! +//! The capture is an unusually exact oracle. It read the abbey's uploaded MOCV vertex buffers back +//! off the D3D stream and diffed them against the file: **678/678 vertices byte-identical in group 1, +//! 496/506 in group 3** — the fade is the whole difference, and it is exactly 10 vertices, all at the +//! one portal whose far side is an EXTERIOR group, all hard-set to `(255,255,255,255)`. Interior↔ +//! interior portals whiten nothing, "including vertices AT portal corners". +//! +//! That single pair of counts pins every part of the mechanism at once — the exterior-neighbour gate, +//! the containment half of the distance kernel (an infinite-plane test whitens 12 here, not 10), and +//! the absence of the MOPY alpha pre-pass (which would rewrite 372 of group 3's alphas and blow the +//! "byte-identical" finding apart). Skips when the client isn't present. + +use std::path::PathBuf; + +use benilla_formats::{parse_wmo_root, wmo_group_fixed_colors, Chain}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +/// How many of a group's MOCV slots the fade rewrites, and the mean luminance before → after. +fn fade_census(reader: &Chain, stem: &str, gi: u32) -> (usize, usize, f32, f32) { + let root_bytes = reader.read(&format!("{stem}.wmo")).expect("read root"); + let root = parse_wmo_root(&root_bytes).expect("parse root"); + let gbytes = reader + .read(&format!("{stem}_{gi:03}.wmo")) + .expect("read group"); + let raw = benilla_formats::wmo_group_raw_colors(&gbytes).expect("group carries MOCV"); + let fixed = wmo_group_fixed_colors(&gbytes, &root).expect("group carries MOCV"); + let lum = + |c: [u8; 4]| 0.299 * f32::from(c[2]) + 0.587 * f32::from(c[1]) + 0.114 * f32::from(c[0]); + let n = raw.len().max(1) as f32; + let changed = raw.iter().zip(&fixed).filter(|(a, b)| a != b).count(); + ( + changed, + raw.len(), + raw.iter().map(|&c| lum(c)).sum::() / n, + fixed.iter().map(|&c| lum(c)).sum::() / n, + ) +} + +/// The abbey, against the D3D capture: group 1 untouched, group 3 rewritten in exactly 10 slots. +#[test] +fn abbey_matches_the_reference_capture() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let reader = Chain::open(&data).expect("open vanilla patch chain"); + let stem = "World\\wmo\\Azeroth\\Buildings\\NSabbey\\NSabbey"; + + let (changed, total, _, _) = fade_census(&reader, stem, 1); + assert_eq!(total, 678, "capture's group 1 upload was 678 vertices"); + assert_eq!( + changed, 0, + "group 1's portals all lead to interior neighbours — the capture read it byte-exact" + ); + + let (changed, total, before, after) = fade_census(&reader, stem, 3); + assert_eq!(total, 506, "capture's group 3 upload was 506 vertices"); + assert_eq!( + changed, 10, + "the capture read 496/506 byte-exact — the fade's whole footprint is 10 slots \ + (an infinite-plane distance test rewrites 12; the MOPY pre-pass would rewrite 372)" + ); + // Those 10 were already near-white in the file (225..254) — the fade is a seam touch-up here, + // not a lighting change. Anything that moves this group's mean has over-fired. + assert!( + (before - after).abs() < 0.5, + "abbey g003 mean luminance moved {before} → {after}" + ); +} + +/// Dire Maul's entrance corridors — why the fade is not optional. Each of the five short transition +/// passages floors its walkway at MOCV `(10,10,40, α=0)`, a near-black navy the interior TRANS law +/// renders literally: a black floor between lit walls (the director's report, 2026-08-04). The floor +/// quad's corners sit *in* the doorway portals, so the fade takes them white. +#[test] +fn dire_maul_entrance_corridor_floors_are_lit() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let reader = Chain::open(&data).expect("open vanilla patch chain"); + let stem = "World\\wmo\\Dungeon\\KL_Diremaul\\KL_Diremaul"; + + for gi in [12, 14, 17, 27, 31] { + let root_bytes = reader.read(&format!("{stem}.wmo")).expect("read root"); + let root = parse_wmo_root(&root_bytes).expect("parse root"); + let gbytes = reader + .read(&format!("{stem}_{gi:03}.wmo")) + .expect("read group"); + let raw = benilla_formats::wmo_group_raw_colors(&gbytes).expect("MOCV"); + let fixed = wmo_group_fixed_colors(&gbytes, &root).expect("MOCV"); + + // The authored walkway: the near-black navy, alpha 0 — the "unlit, the fade lights me" bake. + let dark = raw.iter().filter(|c| **c == [40, 10, 10, 0]).count(); + assert!( + dark >= 4, + "g{gi:03} should author its walkway quad at BGRA (40,10,10,0); found {dark} such slots" + ); + // After the fade every one of them reads white: `tex × white` at full lit weight. Not always + // *exactly* 255 — a corner a couple of centimetres off the portal plane takes the partial + // lerp at `t ≈ 0.997` and lands on 254 — so the assertion is "no longer dark", which is the + // claim that matters. 13 → ~255 luminance is the whole bug. + for (i, (r, f)) in raw.iter().zip(&fixed).enumerate() { + if *r == [40, 10, 10, 0] { + assert!( + f.iter().all(|&c| c >= 250), + "g{gi:03} v{i}: the corridor floor must whiten, or it renders black — got {f:?}" + ); + } + } + } +} diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index c06fa520..3ae14098 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -530,6 +530,19 @@ pub enum SessionEvent { /// the same way). item_caster: Option, }, + /// The hop list for a caster's **beam** (`SMSG_SPELL_UPDATE_CHAIN_TARGETS`, decision 0955): + /// the units a chain/beam visual runs through, caster → t1 → t2 → …. The client's own array + /// (`unit+0xd44`) is filled from this and from nothing else, and is consumed once by the next + /// chain `CharProc` — so this is an edge, not a state. + /// + /// vmangos sends it only for **channeled** spells. The cast-stage chains get their hops from + /// [`Self::SpellGo`]'s hit list instead — which is the reference's own second producer + /// (`0x6e800d` inside its SPELL_GO handler fills the same array), not a divergence. + SpellChainTargets { + caster: u64, + spell_id: u32, + targets: Vec, + }, /// An observed cast was interrupted/cancelled (`SMSG_SPELL_FAILED_OTHER`) — ends the caster's /// `Casting` state seam the same as a [`Self::SpellGo`]. SpellFailedOther { caster: u64, spell_id: u32 }, diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index 7f0354ea..9c1e1bb0 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -158,6 +158,11 @@ pub fn decode(packet: ServerPacket) -> Vec { ammo_display_id: s.ammo_display_id, item_caster: (s.item_or_caster != s.caster).then_some(s.item_or_caster), }], + ServerPacket::SpellChainTargets(c) => vec![SessionEvent::SpellChainTargets { + caster: c.caster, + spell_id: c.spell_id, + targets: c.targets, + }], ServerPacket::SpellDelayed { caster, delay_ms } => { vec![SessionEvent::SpellDelayed { caster, delay_ms }] } diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 2cfffb01..513619e5 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -154,7 +154,7 @@ pub use social::{ pub use spellbook::SpellCooldown; pub use spells::{ cancel_aura, cast_spell, cast_spell_at_dest, cast_spell_gameobject, cast_spell_item, - CastOutcome, SpellCastTargets, SpellGo, SpellStart, + CastOutcome, SpellCastTargets, SpellChainTargets, SpellGo, SpellStart, }; pub use taxi::{ activate_taxi, activate_taxi_express, taxi_node_status_query, taxi_query_available_nodes, diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index b8e77699..22a6cc10 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -144,6 +144,12 @@ pub const SMSG_PLAY_SPELL_VISUAL: u16 = 0x01F3; // 499 pub const SMSG_CANCEL_AUTO_REPEAT: u16 = 0x029C; // 668 pub const SMSG_SPELL_FAILED_OTHER: u16 = 0x02A6; // 678 +/// `SMSG_SPELL_UPDATE_CHAIN_TARGETS` (VERIFIED vmangos `Opcodes_1_12_1.h`: 816) — the **only** +/// source of the client's chain-target list, and so the only way a beam gets more than one hop. +/// The client's handler is `0x6e9820`; it fills the growable array at `unit+0xd44`, which the +/// chain `CharProc` consumes once and zeroes (decision 0955). Body in [`super::spells`]. +pub const SMSG_SPELL_UPDATE_CHAIN_TARGETS: u16 = 0x0330; // 816 + // The cooldown wire (VERIFIED vmangos `Opcodes_1_12_1.h`: 308/176/309/478/481; the client // handlers are byte-verified in wow-re `wave-handlers.md` — 0x6e9460/0x6e95d0/0x6e9670/0x6e9730; // decision 0137 phase 4). Bodies in [`super::spells`]. diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index 0a7c2ed7..9cec7eff 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -12,9 +12,9 @@ use super::{ LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart, MoveMode, Object, PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate, ResurrectRequestBody, - SpeedKind, SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog, - SpellLogMiss, SpellStart, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, - TransportPose, VendorItem, WhoResults, XpGain, + SpeedKind, SpellChainTargets, SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo, + SpellHealLog, SpellLogMiss, SpellStart, TaxiMask, TradeStatus, TradeStatusExtended, + TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain, }; /// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this @@ -413,6 +413,7 @@ pub enum ServerPacket { /// The server schedules impact itself off `Spell.dbc` Speed — nothing about missile travel /// rides this packet (layout in [`super::spells::read_spell_go`]). SpellGo(SpellGo), + SpellChainTargets(SpellChainTargets), /// `SMSG_SPELL_FAILED_OTHER` — an observed cast was interrupted/cancelled (vmangos /// `Spell::SendInterrupted`); our own cast's failure is [`Self::CastResult`] instead. SpellFailedOther { @@ -1099,6 +1100,7 @@ impl ServerPacket { ServerPacket::AiReaction { .. } => "SMSG_AI_REACTION".into(), ServerPacket::SpellStart(_) => "SMSG_SPELL_START".into(), ServerPacket::SpellGo(_) => "SMSG_SPELL_GO".into(), + ServerPacket::SpellChainTargets(_) => "SMSG_SPELL_UPDATE_CHAIN_TARGETS".into(), ServerPacket::SpellFailedOther { .. } => "SMSG_SPELL_FAILED_OTHER".into(), ServerPacket::SpellDelayed { .. } => "SMSG_SPELL_DELAYED".into(), ServerPacket::CancelAutoRepeat => "SMSG_CANCEL_AUTO_REPEAT".into(), diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index 8b51c3fe..f9246903 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -410,6 +410,9 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result { } opcode::SMSG_SPELL_START => ServerPacket::SpellStart(spells::read_spell_start(&mut r)?), opcode::SMSG_SPELL_GO => ServerPacket::SpellGo(spells::read_spell_go(&mut r)?), + opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS => { + ServerPacket::SpellChainTargets(spells::read_spell_chain_targets(&mut r)?) + } opcode::SMSG_SPELL_FAILED_OTHER => { let (caster, spell_id) = spells::read_spell_failed_other(&mut r)?; ServerPacket::SpellFailedOther { caster, spell_id } diff --git a/crates/benilla-protocol/src/messages/spells.rs b/crates/benilla-protocol/src/messages/spells.rs index 45944fb7..f8651414 100644 --- a/crates/benilla-protocol/src/messages/spells.rs +++ b/crates/benilla-protocol/src/messages/spells.rs @@ -434,3 +434,50 @@ mod tests { ); } } + +/// One decoded `SMSG_SPELL_UPDATE_CHAIN_TARGETS` — the **beam's hop list** (decision 0955). +/// +/// VERIFIED on both sides. Server: vmangos `Spell::SendChannelStart` (`Spell.cpp:4970-4997`) — +/// `ObjectGuid caster` (raw `u64`) · `u32 spellId` · `u32 count` · that many raw `u64` target +/// guids. Client: the handler `0x6e9820` decodes exactly that shape (`GetGuid` · `GetInt32` · +/// `GetInt32` · `count × GetGuid`) and hands it to the array filler `0x605780`, which writes the +/// growable array at `unit+0xd44`. The chain `CharProc` (`0x60da79`) then consumes it once and +/// zeroes the count (`0x60db72`). +/// +/// **This packet is NOT the only producer** (wow-re `chain-beam-law.md`, which refuted that +/// reading at the bytes): `0x605780` has exactly two callers — this handler's `0x605767`, and +/// **`0x6e800d`, inside `HandleSpellGo` `0x6e7a70`**. The client fills the same hop array from +/// `SMSG_SPELL_GO`'s own hit list, which is what makes a chain spell draw on its very first cast. +/// That matters here because vmangos only ever *sends* this packet from `SendChannelStart`, gated +/// on `SPELL_ATTR_EX_IS_CHANNELED` — so on this server the channel beams (Drain Life, Mind Flay, +/// Health Funnel, Corruption…) arrive by packet and the cast-stage chains (Chain Lightning, Chain +/// Heal, Chain Burn) arrive by the GO path. Both are the reference's own mechanism; benilla's +/// GO-derived hops are **not** a divergence (decision 0955). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpellChainTargets { + pub caster: u64, + pub spell_id: u32, + /// The hop list in wire order. The client's filler (`0x605780`) walks it dropping any entry + /// equal to the caster's own guid, so that filter belongs to the consumer, not to this decode. + pub targets: Vec, +} + +/// Read `SMSG_SPELL_UPDATE_CHAIN_TARGETS`. Guids are **raw** `u64` on both ends of the list — the +/// server writes `ObjectGuid` and the client reads `GetGuid` (0x4190b0), never the packed form. +pub(super) fn read_spell_chain_targets(r: &mut impl Read) -> io::Result { + let caster = read_u64_le(r)?; + let spell_id = read_u32_le(r)?; + let count = read_u32_le(r)?; + // The count is server-written and unbounded on the wire; read defensively rather than + // pre-allocating from it (the `with_capacity` idiom the rest of this module uses is safe only + // where a `u8` bounds the count). + let mut targets = Vec::new(); + for _ in 0..count { + targets.push(read_u64_le(r)?); + } + Ok(SpellChainTargets { + caster, + spell_id, + targets, + }) +} diff --git a/crates/benilla-protocol/tests/spells.rs b/crates/benilla-protocol/tests/spells.rs index e277fd10..507735b7 100644 --- a/crates/benilla-protocol/tests/spells.rs +++ b/crates/benilla-protocol/tests/spells.rs @@ -912,3 +912,84 @@ fn combat_log_wire_golden() { other => panic!("level up event, got {other:?}"), } } + +/// `SMSG_SPELL_UPDATE_CHAIN_TARGETS` — the beam's hop list (decision 0955). Byte-exact against +/// vmangos `Spell::SendChannelStart` (`Spell.cpp:4970-4997`), which writes an `ObjectGuid` (raw +/// `u64`, never packed), then `u32 spellId`, then a `u32` count, then that many raw guids. The +/// client's handler `0x6e9820` decodes exactly that shape. +/// +/// This is the packet the whole beam system hangs off: it is the **only** producer of the client's +/// chain-target array (`unit+0xd44`), so a decode slip here reads as "the beams came back wrong" +/// rather than as a parse error. +#[test] +fn spell_chain_targets_wire() { + use benilla_protocol::messages::SpellChainTargets; + + const CASTER: u64 = 0x0000_01f1_3045_2ac9; + + // Drain Life (689) channelled from a creature onto one target — the common case, count 1. + let body = hx(concat!( + "c92a4530f1010000", // caster guid, RAW u64 (not packed) + "b1020000", // spellId 689 (Drain Life) + "01000000", // count 1 — a u32, not the u8 the GO lists use + "aa00000000000000", // target guid 0xAA, raw + )); + let packet = + messages::parse_server(messages::opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS, &body).unwrap(); + match &packet { + ServerPacket::SpellChainTargets(c) => assert_eq!( + c, + &SpellChainTargets { + caster: CASTER, + spell_id: 689, + targets: vec![0xAA], + } + ), + other => panic!("chain targets, got {}", other.name()), + } + match decode(packet).pop().unwrap() { + SessionEvent::SpellChainTargets { + caster, + spell_id, + targets, + } => assert_eq!((caster, spell_id, targets), (CASTER, 689, vec![0xAA])), + other => panic!("chain targets event, got {other:?}"), + } + + // A three-hop chain, hop order preserved: the beam runs caster -> t1 -> t2 -> t3. + let body = hx(concat!( + "0100000000000000", + "a5010000", // spellId 421 (Chain Lightning) + "03000000", + "aa00000000000000", + "bb00000000000000", + "cc00000000000000", + )); + match messages::parse_server(messages::opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS, &body).unwrap() + { + ServerPacket::SpellChainTargets(c) => { + assert_eq!(c.targets, vec![0xAA, 0xBB, 0xCC], "wire order is hop order"); + } + other => panic!("chain targets, got {}", other.name()), + } + + // An empty list is legal on the wire (the server only *sends* when it has hits, but the shape + // is a count) and must decode rather than error. + let body = hx(concat!("0100000000000000", "a5010000", "00000000")); + match messages::parse_server(messages::opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS, &body).unwrap() + { + ServerPacket::SpellChainTargets(c) => assert!(c.targets.is_empty()), + other => panic!("chain targets, got {}", other.name()), + } + + // A truncated body is an error, not a silent short list — the count says 2, one guid follows. + let body = hx(concat!( + "0100000000000000", + "a5010000", + "02000000", + "aa00000000000000", + )); + assert!( + messages::parse_server(messages::opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS, &body).is_err() + ); +} diff --git a/crates/benilla-ui/src/loader/tests.rs b/crates/benilla-ui/src/loader/tests.rs index a0f81c8b..5b6d5c95 100644 --- a/crates/benilla-ui/src/loader/tests.rs +++ b/crates/benilla-ui/src/loader/tests.rs @@ -1000,4 +1000,85 @@ mod region_template_tests { ); assert_eq!(s.eval::("return Label:GetText()").unwrap(), "hello"); } + + /// `atlas=` on a Button/CheckButton state texture and on a Slider `` — the Era + /// dialect (0950) extended to the widget texture paths (0957): the slot is created empty and + /// resolved through SetAtlas, so the region answers GetAtlas and (with `useAtlasSize`) wears + /// the member's nominal size. The byte-verified 1.12 loader tables know only file=/. + #[test] + fn state_textures_and_the_thumb_take_the_atlas_attr() { + let mut s = UiScript::new().unwrap(); + s.set_screen_size(800.0, 600.0); + s.set_era_atlases([ + ( + "checkbox-minimal".to_string(), + crate::script::EraAtlasEntry { + file: "era:textures/4614134.blp".to_string(), + uv: [1.0 / 64.0, 31.0 / 64.0, 1.0 / 64.0, 30.0 / 64.0], + size: [30.0, 29.0], + }, + ), + ( + "checkmark-minimal".to_string(), + crate::script::EraAtlasEntry { + file: "era:textures/4614134.blp".to_string(), + uv: [1.0 / 64.0, 31.0 / 64.0, 32.0 / 64.0, 61.0 / 64.0], + size: [30.0, 29.0], + }, + ), + ( + "minimal_sliderbar_button".to_string(), + crate::script::EraAtlasEntry { + file: "era:textures/4567914.blp".to_string(), + uv: [1.0 / 128.0, 21.0 / 128.0, 20.0 / 128.0, 39.0 / 128.0], + size: [20.0, 19.0], + }, + ), + ]); + let doc = parse( + r#" + + + + + + + + + + + + "#, + ) + .expect("valid FrameXML"); + let report = load(&s, &doc, &no_files); + assert!(report.errors.is_empty(), "errors: {:?}", report.errors); + assert_eq!( + s.eval::("return Box:GetNormalTexture():GetAtlas()") + .unwrap(), + "checkbox-minimal" + ); + assert_eq!( + s.eval::("return Box:GetCheckedTexture():GetAtlas()") + .unwrap(), + "checkmark-minimal" + ); + assert_eq!( + s.eval::("return Bar:GetThumbTexture():GetAtlas()") + .unwrap(), + "minimal_sliderbar_button" + ); + // useAtlasSize rode along — the thumb wears the member's nominal 20×19 (what the + // engine's thumb-travel math reads). + assert_eq!( + s.eval::("return Bar:GetThumbTexture():GetWidth()") + .unwrap(), + 20.0 + ); + assert_eq!( + s.eval::("return Bar:GetThumbTexture():GetHeight()") + .unwrap(), + 19.0 + ); + } } diff --git a/crates/benilla-ui/src/loader/widgets.rs b/crates/benilla-ui/src/loader/widgets.rs index 47af04c4..75534644 100644 --- a/crates/benilla-ui/src/loader/widgets.rs +++ b/crates/benilla-ui/src/loader/widgets.rs @@ -85,11 +85,24 @@ impl Loader<'_> { this.call(wrapper, method, file.to_string(), dbg); } else if let Some(c) = children_named(t, "Color").next().map(color_of) { this.call(wrapper, method, (c[0], c[1], c[2], c[3]), dbg); + } else if t.attr("atlas").is_some() { + // `atlas=` (decision 0950, the Era dialect — same seam as a `` + // texture's): the byte-verified 1.12 table knows only file=/, so the + // setter first creates the slot empty and SetAtlas below resolves it. + this.call(wrapper, method, String::new(), dbg); } // alphaMode / / / apply to the region the setter just // created — fetch it back through the matching getter and use the region methods. let getter = method.replacen("Set", "Get", 1); if let Ok(region) = wrapper.call_method::(getter.as_str(), ()) { + if let Some(atlas) = t.attr("atlas") { + this.call_region( + ®ion, + "SetAtlas", + (atlas.to_string(), t.attr_bool("useAtlasSize")), + dbg, + ); + } if let Some(mode) = t.attr("alphaMode") { this.call_region(®ion, "SetBlendMode", mode.to_string(), dbg); } @@ -325,11 +338,28 @@ impl Loader<'_> { ); } else if let Some(c) = children_named(tt, "Color").next().map(color_of) { self.call(wrapper, "SetThumbTexture", (c[0], c[1], c[2], c[3]), dbg); + } else if tt.attr("atlas").is_some() { + // `atlas=` — the Era dialect (0950), same posture as the Button state textures + // above: create the slot empty, resolve via SetAtlas below. + self.call( + wrapper, + "SetThumbTexture", + (String::new(), layer.clone()), + dbg, + ); } // alphaMode / / / apply to the thumb region the setter just // created — fetch it back through the getter and use the region methods (same shape as a // Button's state textures). if let Ok(region) = wrapper.call_method::
("GetThumbTexture", ()) { + if let Some(atlas) = tt.attr("atlas") { + self.call_region( + ®ion, + "SetAtlas", + (atlas.to_string(), tt.attr_bool("useAtlasSize")), + dbg, + ); + } if let Some(mode) = tt.attr("alphaMode") { self.call_region(®ion, "SetBlendMode", mode.to_string(), dbg); } diff --git a/crates/benilla-wmo/src/lib.rs b/crates/benilla-wmo/src/lib.rs index 5327dbda..3ff3cef6 100644 --- a/crates/benilla-wmo/src/lib.rs +++ b/crates/benilla-wmo/src/lib.rs @@ -72,6 +72,7 @@ pub struct Uv { } /// A MOCV vertex colour (stored BGRA on disk). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Color { pub b: u8, pub g: u8, diff --git a/crates/benilla/assets/ui/OptionsFrame.xml b/crates/benilla/assets/ui/OptionsFrame.xml index 97284cc1..02b5b654 100644 --- a/crates/benilla/assets/ui/OptionsFrame.xml +++ b/crates/benilla/assets/ui/OptionsFrame.xml @@ -1,8 +1,10 @@ + + + + + + + + + + + + + + + + + + + + + + + + + OptionsCheckbox_OnClick(this) + OptionsRow_Hover(this:GetParent(), 1) + OptionsRow_Hover(this:GetParent(), nil) + + + + + OptionsRow_Hover(this, 1) + OptionsRow_Hover(this, nil) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + OptionsSlider_OnValueChanged(this, arg1) + OptionsRow_Hover(this:GetParent():GetParent(), 1) + OptionsRow_Hover(this:GetParent():GetParent(), nil) + + + + + + + + + OptionsRow_Hover(this, 1) + OptionsRow_Hover(this, nil) + + + @@ -516,20 +860,130 @@ - + - + + + + + + + + + + + + + + + OptionsRow_OnLoad(self, "MasterSoundEffects", "Enable All Sound") + + + + + + + OptionsRow_OnLoad(self, "MasterVolume", "Master Volume") + + + + + + + OptionsRow_OnLoad(self, "SoundVolume", "Sound Volume", 1) + + + + + + + OptionsRow_OnLoad(self, "MusicVolume", "Music Volume", 1) + + + + + + + OptionsRow_OnLoad(self, "AmbienceVolume", "Ambience Volume", 1) + + + + + + + OptionsRow_OnLoad(self, "EnableMusic", "Enable Music") + + + + + + + OptionsRow_OnLoad(self, "EnableAmbience", "Enable Ambience") + + + + + + + + + + + + + + + + + OptionsRow_OnLoad(self, "uiScale", "UI Scale") + OptionsSliderRow_Setup(self, 0.64, 1.0, 0.01) + + + + + + + + + OptionsRow_OnLoad(self, "farclip", "Terrain Distance") + OptionsSliderRow_Setup(self, 177, 777, 60, "raw") + + + + + diff --git a/crates/benilla/src/capture/fixtures.rs b/crates/benilla/src/capture/fixtures.rs index 136e75b9..c37eaa90 100644 --- a/crates/benilla/src/capture/fixtures.rs +++ b/crates/benilla/src/capture/fixtures.rs @@ -931,6 +931,33 @@ pub(super) fn seed_ui_fixture( warn!("capture: ui-options seed failed to open the window: {e}"); } } + UiFixture::OptionsAudio => { + let Some(mut script) = script else { + return; + }; + // The Audio page (0957): register the real CVar set first — the hermetic capture has + // no CvarPlugin file load to race, and the rows must read real values, not the + // nil-tolerant zeros — then open and select through the live paths. + script.register_cvars(crate::cvars::REGISTERED.iter().copied()); + if let Err(e) = + script.run("ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowAudio:Click()") + { + warn!("capture: ui-options-audio seed failed: {e}"); + } + } + UiFixture::OptionsGraphics => { + let Some(mut script) = script else { + return; + }; + // The Graphics page (0959), same posture as the Audio fixture: real CVar set, live + // open-and-select paths. + script.register_cvars(crate::cvars::REGISTERED.iter().copied()); + if let Err(e) = + script.run("ShowUIPanel(OptionsFrame); OptionsFrameCategoryListRowGraphics:Click()") + { + warn!("capture: ui-options-graphics seed failed: {e}"); + } + } UiFixture::SpellBook => { // The director's own report reproduced (decision 0228): a HUMAN WARRIOR (race 1, // class 1) who learned Fireball + Mind Flay via a GM command during testing. The book diff --git a/crates/benilla/src/capture/scenarios.rs b/crates/benilla/src/capture/scenarios.rs index fdd55d4b..b37eebf2 100644 --- a/crates/benilla/src/capture/scenarios.rs +++ b/crates/benilla/src/capture/scenarios.rs @@ -93,6 +93,18 @@ pub(super) enum UiFixture { /// search-box seat, category list art, the window scale. Static (no server state touched), /// so its pixels move only when the window or the atlas seam does. Options, + /// The Options window ON THE AUDIO PAGE (decision 0957) — the setting-row look instrument: + /// checkbox art at its era seats, the minimal_sliderbar three-slice + thumb + steppers, + /// child-row indent/small-font, the percent readouts, Defaults armed. Rows read the CVar + /// registration defaults (hermetic capture = no config file), so the pixels move only with + /// the window, the atlas seam, or a registered default. + OptionsAudio, + /// The Options window ON THE GRAPHICS PAGE (decision 0959) — the two 1.12 Video Options + /// sliders on the era row seats: uiScale at its 0.64..1.0 panel range with the percent + /// readout, farclip at 177..777 with the raw-yards readout. Rows read the CVar registration + /// defaults (hermetic capture = no config file), so the pixels move only with the window, + /// the atlas seam, or a registered default. + OptionsGraphics, /// A **floating overhead name with the river surface behind it** — the world-text-vs-liquid /// draw-order instrument. A named unit stands 25 yd out in the Elwynn river (the `water-noon` /// camera), so its name projects onto the water *beyond* it: the exact geometry of the @@ -826,6 +838,26 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ minute: 720, ui: Some(UiFixture::Options), }, + // The same window on the AUDIO page (0957). Run with + // `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-audio`. + Scenario { + name: "ui-options-audio", + map: MAP_AZEROTH, + eye: GROUND_EYE, + look: GROUND_LOOK, + minute: 720, + ui: Some(UiFixture::OptionsAudio), + }, + // The same window on the GRAPHICS page (0959). Run with + // `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-graphics`. + Scenario { + name: "ui-options-graphics", + map: MAP_AZEROTH, + eye: GROUND_EYE, + look: GROUND_LOOK, + minute: 720, + ui: Some(UiFixture::OptionsGraphics), + }, // An overhead NAME with the river surface behind it — the world-text-vs-liquid draw-order // instrument (see [`UiFixture::NameWater`]). Same camera as `water-noon`, plus a named unit // out in the water. Run with `WOW_CAPTURE=name-water`. diff --git a/crates/benilla/src/cvars.rs b/crates/benilla/src/cvars.rs index 617da869..7e52fc88 100644 --- a/crates/benilla/src/cvars.rs +++ b/crates/benilla/src/cvars.rs @@ -39,6 +39,13 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[ ("SoundVolume", "1"), ("MusicVolume", "0.4"), ("AmbienceVolume", "0.6"), + // The three 1.12 sound enables (registrar defaults all "1", wow-re B10): + // `MasterSoundEffects` is the MASTER "Enable All Sound" checkbox (SoundOptionsFrame.lua + // index 1 — its callback sets the engine-wide pause flag), NOT an SFX-only toggle; 1.12 + // has no `EnableSound`/`EnableSFX` at all. + ("MasterSoundEffects", "1"), + ("EnableMusic", "1"), + ("EnableAmbience", "1"), ("uiScale", "0.9"), ("farclip", "777"), ]; @@ -100,6 +107,10 @@ fn apply_to_knobs( "soundvolume" => sound.sfx = v.clamp(0.0, 1.0), "musicvolume" => sound.music = v.clamp(0.0, 1.0), "ambiencevolume" => sound.ambience = v.clamp(0.0, 1.0), + // The enables are 0/1 flags; the client's own parse is int + `!= 0`. + "mastersoundeffects" => sound.enabled = v != 0.0, + "enablemusic" => sound.music_enabled = v != 0.0, + "enableambience" => sound.ambience_enabled = v != 0.0, "uiscale" => scale.0 = v.clamp(0.5, 1.5), "farclip" => view.farclip = v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end()), _ => return false, @@ -180,11 +191,15 @@ fn sync_cvars( }; if !persist.registered { script.register_cvars(REGISTERED.iter().copied()); - let session: [(&str, String); 6] = [ + let flag = |b: bool| if b { "1" } else { "0" }.to_string(); + let session: [(&str, String); 9] = [ ("MasterVolume", sound.master.to_string()), ("SoundVolume", sound.sfx.to_string()), ("MusicVolume", sound.music.to_string()), ("AmbienceVolume", sound.ambience.to_string()), + ("MasterSoundEffects", flag(sound.enabled)), + ("EnableMusic", flag(sound.music_enabled)), + ("EnableAmbience", flag(sound.ambience_enabled)), ("uiScale", scale.0.to_string()), ("farclip", view.farclip.to_string()), ]; @@ -291,6 +306,9 @@ mod tests { assert_eq!(d["SoundVolume"], sound.sfx); assert_eq!(d["MusicVolume"], sound.music); assert_eq!(d["AmbienceVolume"], sound.ambience); + assert_eq!(d["MasterSoundEffects"] != 0.0, sound.enabled); + assert_eq!(d["EnableMusic"] != 0.0, sound.music_enabled); + assert_eq!(d["EnableAmbience"] != 0.0, sound.ambience_enabled); assert_eq!(d["uiScale"], DEFAULT_UI_SCALE); // ViewDistance::default() reads $WOW_FARCLIP; the registered default mirrors the // env-less 777 literal (view.rs doc: "Default 777"). @@ -323,6 +341,23 @@ mod tests { "farclip", "50", &mut sound, &mut scale, &mut view )); assert_eq!(view.farclip, *FARCLIP_RANGE.start()); + // Enable flags: any nonzero is on, zero is off (the client's int-parse + != 0). + assert!(apply_to_knobs( + "EnableMusic", + "0", + &mut sound, + &mut scale, + &mut view + )); + assert!(!sound.music_enabled); + assert!(apply_to_knobs( + "mastersoundeffects", + "1", + &mut sound, + &mut scale, + &mut view + )); + assert!(sound.enabled); // A bad value is consumed (known key) and the resource keeps its truth. assert!(apply_to_knobs( "uiScale", "banana", &mut sound, &mut scale, &mut view diff --git a/crates/benilla/src/entities/dest_fx.rs b/crates/benilla/src/entities/dest_fx.rs index 5f39928c..8f91bf85 100644 --- a/crates/benilla/src/entities/dest_fx.rs +++ b/crates/benilla/src/entities/dest_fx.rs @@ -63,11 +63,13 @@ const SHARD_MODELS: [&str; 7] = [ /// kit column is shared, the consumers are not. const PROC_TYPE_SHARD_EMITTER: i32 = 9; -/// The exact small-int decode the client applies to `CharParamZero` (`bits(p0 + 512.0) >> 14 & -/// 0xff`) — clamped into [`SHARD_MODELS`] because the client itself has **no bounds check** -/// (`mov cl,al` — data ≥ 7 reads past the table; wow-re trap #2). +/// The exact small-int decode the client applies to `CharParamZero` +/// ([`benilla_formats::char_proc_small_int`] — the one idiom every integer-in-a-float-column proc +/// uses, the chain proc included; decision 0955 lifted it into the format crate) — clamped into +/// [`SHARD_MODELS`] because the client itself has **no bounds check** (`mov cl,al` — data ≥ 7 +/// reads past the table; wow-re trap #2). fn shard_model_index(param0: f32) -> usize { - let idx = ((param0 + 512.0).to_bits() >> 14 & 0xff) as usize; + let idx = benilla_formats::char_proc_small_int(param0) as usize; idx.min(SHARD_MODELS.len() - 1) } diff --git a/crates/benilla/src/main.rs b/crates/benilla/src/main.rs index 086b53ad..11558be0 100644 --- a/crates/benilla/src/main.rs +++ b/crates/benilla/src/main.rs @@ -324,7 +324,9 @@ fn main() -> AppExit { Ok("ui-worldmap") => UVec2::new(1100, 800), // The 920×724 era options window wants air on every side so the // straddling right-edge tile and the hung close X stay in frame. - Ok("ui-options") => UVec2::new(1200, 900), + Ok("ui-options") + | Ok("ui-options-audio") + | Ok("ui-options-graphics") => UVec2::new(1200, 900), _ => UVec2::new(640, 700), } } diff --git a/crates/benilla/src/net/apply.rs b/crates/benilla/src/net/apply.rs index 035857b3..5c4f2ebd 100644 --- a/crates/benilla/src/net/apply.rs +++ b/crates/benilla/src/net/apply.rs @@ -48,7 +48,8 @@ use quests::{ use spells::{ action_buttons, aura_duration, cancel_auto_repeat, cast_result, channel_start, channel_update, clear_cooldown, cooldown_cheat, cooldown_event, item_cooldown, learned_spell, spell_book, - spell_cooldowns, spell_delayed, spell_failed_other, spell_go, spell_start, superceded_spell, + spell_chain_targets, spell_cooldowns, spell_delayed, spell_failed_other, spell_go, spell_start, + superceded_spell, }; // ── The per-frame bridge systems ───────────────────────────────────────────────────────────────── @@ -1004,6 +1005,11 @@ pub(super) fn apply_net_updates( ), play_seq.next(), ), + SessionEvent::SpellChainTargets { + caster, + spell_id, + targets, + } => spell_chain_targets(caster, spell_id, targets, &index), SessionEvent::SpellFailedOther { caster, spell_id } => spell_failed_other( caster, spell_id, diff --git a/crates/benilla/src/net/apply/spells.rs b/crates/benilla/src/net/apply/spells.rs index cf3ec871..838fba68 100644 --- a/crates/benilla/src/net/apply/spells.rs +++ b/crates/benilla/src/net/apply/spells.rs @@ -692,6 +692,35 @@ pub(super) fn aura_duration( durations.set(slot, remaining_ms, now_secs); } +/// `SMSG_SPELL_UPDATE_CHAIN_TARGETS` → the hop list a **beam** visual runs through (0955). +/// +/// The reference parks it on the caster (the growable array at `unit+0xd44`: capacity `+0xd44`, +/// count `+0xd48`, data `+0xd4c`, alloc quantum `+0xd50`), **dropping any entry equal to the +/// caster's own guid** while it fills, and the next chain `CharProc` consumes it once — +/// `0x60db72` zeroes the count on every path. This packet is one of **two** producers: the +/// reference fills the same array from `SMSG_SPELL_GO`'s hit list as well (`0x6e800d` inside its +/// GO handler), which is how a non-channeled chain spell draws at all. +/// +/// The beam renderer is the next slice, so this arm resolves and reports the hops rather than +/// parking them — a packet that arrives and is visible in the trace, with no half-built state +/// hanging off a unit waiting for a consumer. +pub(super) fn spell_chain_targets( + caster: u64, + spell_id: u32, + targets: Vec, + index: &GuidIndex, +) { + let known = targets + .iter() + .filter(|&&g| g != caster) + .filter(|g| index.0.contains_key(g)) + .count(); + debug!( + "net: chain targets for spell {spell_id} by {caster:#x} — {} hop(s), {known} streamed", + targets.len() + ); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla/src/pipe_warm/menagerie.rs b/crates/benilla/src/pipe_warm/menagerie.rs index 6062339b..39039787 100644 --- a/crates/benilla/src/pipe_warm/menagerie.rs +++ b/crates/benilla/src/pipe_warm/menagerie.rs @@ -57,14 +57,23 @@ pub(super) struct WarmLanes<'w> { standard: ResMut<'w, Assets>, /// The fallback cube (0938): production mesh + materials, drawn while a model streams. cubes: Option>, + /// The image store (0958): the twin booth's render target and the effect-lane warm's + /// stand-in texture are created here for the life of the pass. + pub(super) images: ResMut<'w, Assets>, } /// One portrait/paperdoll booth camera + its layer ([`crate::portrait`], 0938): the booths run /// `Msaa::Off`, so every model-lane pipeline has a samples=1 twin that otherwise compiles live /// on the first in-world portrait (the first click-target). The booth cameras exist from -/// `Startup` and render every frame, so menagerie rigs duplicated onto ONE booth's layer compile -/// that whole twin space behind the cover — the view shape (HDR, no tonemap, the glow node) is -/// otherwise the world camera's, so samples is the only twin axis. +/// `Startup` and render during the warm window (the demand gate counts the pass as demand), so +/// menagerie rigs duplicated onto ONE booth's layer compile that twin space behind the cover — +/// the view shape (HDR, no tonemap, the glow node) is otherwise the world camera's, leaving TWO +/// twin axes: samples, and the projection CLASS (0958). A real booth carries the Perspective +/// placeholder until its first bake installs `Projection::custom(WowPortraitProjection)` — a +/// distinct bevy_pbr view key — so the rigs are ALSO duplicated onto the pass's own twin booth +/// ([`crate::portrait::spawn_warm_booth`]), which is that custom-projection space; 0938 warmed +/// only the placeholder class, and the whole samples=1 space compiled again, live, on the first +/// target portrait. pub(super) type BoothCamQuery<'w, 's> = Query< 'w, 's, @@ -85,6 +94,7 @@ pub(super) fn spawn_menagerie( commands: &mut Commands, cam: Entity, booth: Option<(Entity, &bevy::camera::visibility::RenderLayers)>, + warm_booth: &(Entity, bevy::camera::visibility::RenderLayers), meshes: &mut Assets, materials: &mut Assets, lanes: &mut WarmLanes, @@ -145,21 +155,71 @@ pub(super) fn spawn_menagerie( } } } - // The additive glow-card blend state (specialize's pure ONE/ONE add), both depth-write - // flavours, and the doodad/entity distance-fade blend twin (depth-write forced on). - for no_depth_write in [false, true] { - mats.push(model_material( + // The additive glow-card blend state (specialize's pure ONE/ONE add) and the + // doodad/entity distance-fade blend twin, each over the FULL depth-flag cross: the + // production builders forward the source batch's 0x10/0x08 flags verbatim into both + // (`assemble.rs` / `display.rs` / `particles/model.rs`), so every combination is + // authorable — 0938 pinned the additive rows' depth-test and the fade row's both flags + // false, and the sweep behind 0958 found the pinned keys reachable. + for additive_not_fade in [true, false] { + for no_depth_write in [false, true] { + for no_depth_test in [false, true] { + mats.push(model_material( + cache, + materials, + None, + ModelBlend::Blend, + two_sided, + false, + false, + false, + additive_not_fade, + !additive_not_fade, + no_depth_write, + no_depth_test, + FogPolicy::Scene, + ShadeSel::Lit, + 0, + None, + None, + None, + None, + false, + light, + )); + } + } + } + // The depth-prime twin (colour writes masked off), plain and cutout. + for cutout in [false, true] { + mats.push(zfill_material( + cache, materials, None, two_sided, cutout, light, + )); + } + } + // The ground-clutter lane (specialize's over-blend), both sidednesses — the first + // verification leg caught the two-sided one compiling live. Its material is built by + // `WorldAssets::model_material` (image machinery this pass doesn't need) — the pipeline only + // sees the KEY bits, so arm `clutter_fade` on a COPY of the plain material (a fresh asset; + // the dedup cache's own entry stays untouched). ALL THREE alpha modes the clutter builder + // maps to (Opaque / Mask / Blend — Mod/Mod2x fold to Blend there), not just Mask: a detail + // doodad's trunk batch is Opaque, its canopy Blend, and the builder's untextured fallback is + // Opaque + back-cull with the fade still armed — each its own key (0958's sweep; 0938 warmed + // Mask only). + for two_sided in [false, true] { + for blend in [ModelBlend::Opaque, ModelBlend::AlphaTest, ModelBlend::Blend] { + let plain = model_material( cache, materials, None, - ModelBlend::Blend, + blend, two_sided, false, false, false, - true, false, - no_depth_write, + false, + false, false, FogPolicy::Scene, ShadeSel::Lit, @@ -170,72 +230,13 @@ pub(super) fn spawn_menagerie( None, false, light, - )); - } - mats.push(model_material( - cache, - materials, - None, - ModelBlend::Blend, - two_sided, - false, - false, - false, - false, - true, - false, - false, - FogPolicy::Scene, - ShadeSel::Lit, - 0, - None, - None, - None, - None, - false, - light, - )); - // The depth-prime twin (colour writes masked off), plain and cutout. - for cutout in [false, true] { - mats.push(zfill_material( - cache, materials, None, two_sided, cutout, light, - )); - } - } - // The ground-clutter lane (Mask + specialize's over-blend), both sidednesses — the first - // verification leg caught the two-sided one compiling live. Its material is built by - // `WorldAssets::model_material` (image machinery this pass doesn't need) — the pipeline only - // sees the KEY bits, so arm `clutter_fade` on a COPY of the plain Mask material (a fresh - // asset; the dedup cache's own entry stays untouched). - for two_sided in [false, true] { - let mask = model_material( - cache, - materials, - None, - ModelBlend::AlphaTest, - two_sided, - false, - false, - false, - false, - false, - false, - false, - FogPolicy::Scene, - ShadeSel::Lit, - 0, - None, - None, - None, - None, - false, - light, - ); - if let Some(m) = materials.get(&mask) { - let mut m = m.clone(); - m.extension.clutter_fade = Vec4::new(52.5, 70.0, 0.0, 1.0); - let clutter = materials.add(m); - mats.push(clutter); + ); + if let Some(m) = materials.get(&plain) { + let mut m = m.clone(); + m.extension.clutter_fade = Vec4::new(52.5, 70.0, 0.0, 1.0); + let clutter = materials.add(m); + mats.push(clutter); + } } } @@ -298,9 +299,11 @@ pub(super) fn spawn_menagerie( // skinned, plain or vertex-coloured — a submerged character's skinned gear classifies far // too); the shard rows and their far twins ride the static layouts only (shard geometry // models are static meshes by construction — `particles::model`). Every main-cross rig is - // ALSO duplicated onto one portrait booth's layer (0938): the booths render at `Msaa::Off`, - // so the samples=1 twin of each model pipeline otherwise compiles live on the first in-world - // portrait — a unit's gear (any family, any layout, far-swapped included when submerged) + // ALSO duplicated onto one portrait booth's layer (0938) AND the twin booth's (0958): the + // booths render at `Msaa::Off`, so each model pipeline has a samples=1 twin per projection + // CLASS — the real booth's Perspective placeholder and the twin booth's custom projection + // (the class real bakes install) — that otherwise compiles live on the first in-world + // portrait. A unit's gear (any family, any layout, far-swapped included when submerged) // can reach a booth pane. Shard rows can't (particle instances never ride booth layers). for (mesh, aabb, skinned) in &layouts { for mat in mats.iter().chain(far_mats.iter()) { @@ -318,6 +321,16 @@ pub(super) fn spawn_menagerie( ); count += 1; } + spawn_model_rig( + commands, + warm_booth.0, + Some(warm_booth.1.clone()), + mesh, + aabb, + *skinned, + mat, + ); + count += 1; } if !*skinned { for mat in shard_mats.iter().chain(far_shard_mats.iter()) { @@ -429,10 +442,19 @@ pub(super) fn spawn_menagerie( Some(layers.clone()), &cube_mesh, None, - mat, + mat.clone(), &mut count, ); } + spawn_lane_rig( + commands, + warm_booth.0, + Some(warm_booth.1.clone()), + &cube_mesh, + None, + mat, + &mut count, + ); } } let plate = lanes.standard.add(StandardMaterial { @@ -650,26 +672,48 @@ fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh { #[cfg(test)] mod tests { - /// The lane-coverage gate (decision 0938): every 3-D material lane registered anywhere in - /// this crate (`MaterialPlugin::`) must be NAMED in this file — the warm pass is the one - /// place that compiles a lane's pipelines behind the loading cover, so a lane nobody - /// considered for warming is a future director-felt live stall on its first sight. - /// Exemptions carry their reason beside them; anything else red-bars the build until - /// `spawn_menagerie` (or an exemption with a reason) handles it. The runtime half of the - /// contract stays `watch_pipelines`' "compiled LIVE" tripwire, which catches per-VARIANT - /// drift inside a covered lane; this test catches whole lanes. + /// The gate's second half (decision 0958): a lane can also be a hand-rolled + /// `SpecializedRenderPipeline`/`SpecializedMeshPipeline`/`SpecializedComputePipeline` impl — + /// invisible to the `MaterialPlugin` scan below, which is exactly how the `wow_effect` lane + /// (particles, decals, the selection ring) shipped unwarmed and the ring's first-target + /// compile stalled live twice (0837's inventory missed it too). Every such impl's TYPE must + /// be named in the pipe_warm module, or this red-bars the build. #[test] - fn every_material_lane_has_a_warm_contributor() { - // Lanes that never need the menagerie, each with the reason it is safe: - // - TerrainMaterial / WdlMaterial: the ground the player spawns on and its horizon - // ring — always drawn under the entry cover by construction, no per-variant key axis. - let exempt = ["TerrainMaterial", "WdlMaterial"]; + fn every_custom_pipeline_lane_has_a_warm_contributor() { let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - // "Named in this file" = anywhere in the pipe_warm module folder. let warm_src = std::fs::read_to_string(src_root.join("pipe_warm/mod.rs")).unwrap() + &std::fs::read_to_string(src_root.join("pipe_warm/menagerie.rs")).unwrap(); - let mut stack = vec![src_root]; let mut missing = Vec::new(); + for (path, text) in walk_rs(&src_root) { + for needle in [ + "impl SpecializedRenderPipeline for ", + "impl SpecializedMeshPipeline for ", + "impl SpecializedComputePipeline for ", + ] { + for (i, _) in text.match_indices(needle) { + let rest = &text[i + needle.len()..]; + let ty: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !warm_src.contains(&ty) { + missing.push(format!("{ty} (impl in {})", path.display())); + } + } + } + } + assert!( + missing.is_empty(), + "custom pipeline lanes with no pipe_warm contributor: {missing:?} — a lane the \ + menagerie can't see compiles its pipelines live on first draw (decisions \ + 0837/0938/0958)" + ); + } + + /// Every `.rs` file under `src`, read — shared by both lane-coverage scans. + fn walk_rs(src_root: &std::path::Path) -> Vec<(std::path::PathBuf, String)> { + let mut out = Vec::new(); + let mut stack = vec![src_root.to_path_buf()]; while let Some(dir) = stack.pop() { for entry in std::fs::read_dir(&dir).unwrap() { let path = entry.unwrap().path(); @@ -681,16 +725,59 @@ mod tests { continue; } let text = std::fs::read_to_string(&path).unwrap(); - for (i, _) in text.match_indices("MaterialPlugin::<") { - // Skip `UiMaterialPlugin::<…>` and friends: a preceding ident char means - // this is a different plugin family (UI/2D pipelines compile pre-world, - // where every frame counts as covered). + out.push((path, text)); + } + } + out + } + + /// The lane-coverage gate (decision 0938, widened by 0958): every material lane registered + /// anywhere in this crate — 3-D (`MaterialPlugin::`), 2-D (`Material2dPlugin::`), and + /// UI (`UiMaterialPlugin::`) — must be NAMED in this file. The warm pass is the one place + /// that compiles a lane's pipelines behind the loading cover, so a lane nobody considered + /// for warming is a future director-felt live stall on its first sight. 0938 exempted the + /// 2-D/UI families wholesale ("pre-world counts as covered"), which was wrong for + /// `UiQuadMaterial` — its quads only exist in-world, so its one pipeline's compile time was + /// a race with the cover lift (0958's sweep). Exemptions carry their reason beside them; + /// anything else red-bars the build. The runtime half of the contract stays + /// `watch_pipelines`' "compiled LIVE" tripwire, which catches per-VARIANT drift inside a + /// covered lane; this test catches whole lanes. + #[test] + fn every_material_lane_has_a_warm_contributor() { + // Lanes that never need the menagerie, each with the reason it is safe: + // - TerrainMaterial / WdlMaterial: the ground the player spawns on and its horizon + // ring — always drawn under the entry cover by construction, no per-variant key axis. + // - AddUiMaterial: drawn only by the glue screens, and every pre-world frame counts + // as covered (`publish_cover`: `state != InWorld`). + let families: [(&str, &[&str]); 3] = [ + ("MaterialPlugin::<", &["TerrainMaterial", "WdlMaterial"]), + ("Material2dPlugin::<", &[]), + ("UiMaterialPlugin::<", &["AddUiMaterial"]), + ]; + let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + // "Named in this file" = anywhere in the pipe_warm module folder. + let warm_src = std::fs::read_to_string(src_root.join("pipe_warm/mod.rs")).unwrap() + + &std::fs::read_to_string(src_root.join("pipe_warm/menagerie.rs")).unwrap(); + let mut missing = Vec::new(); + for (path, text) in walk_rs(&src_root) { + for (needle, exempt) in families { + for (i, _) in text.match_indices(needle) { + // A preceding ident char means this match is really a longer family's name + // (`Material2dPlugin::<` contains no bare `MaterialPlugin::<`, but + // `UiMaterialPlugin::<` does) — that family gets its own row above. if i > 0 && (text.as_bytes()[i - 1].is_ascii_alphanumeric()) { continue; } - let rest = &text[i + "MaterialPlugin::<".len()..]; + let rest = &text[i + needle.len()..]; let Some(end) = rest.find('>') else { continue }; - let ty = rest[..end].rsplit("::").next().unwrap().trim(); + // A registration may wrap (`UiMaterialPlugin::<\n AddUiMaterial,\n>`): + // strip the path, any trailing turbofish comma, and surrounding whitespace. + let ty = rest[..end] + .rsplit("::") + .next() + .unwrap() + .trim() + .trim_end_matches(','); if exempt.contains(&ty) || warm_src.contains(ty) { continue; } @@ -702,7 +789,7 @@ mod tests { missing.is_empty(), "material lanes with no pipe_warm contributor: {missing:?} — every registered \ lane's pipelines compile behind the loading cover, or its first sight is a live \ - render-thread stall (decisions 0837/0937/0938)" + render-thread stall (decisions 0837/0937/0938/0958)" ); } } diff --git a/crates/benilla/src/pipe_warm/mod.rs b/crates/benilla/src/pipe_warm/mod.rs index 27479683..4319daa0 100644 --- a/crates/benilla/src/pipe_warm/mod.rs +++ b/crates/benilla/src/pipe_warm/mod.rs @@ -19,6 +19,10 @@ //! model-only scope) — parented to the world camera, spawned when the entry cover rises; the //! loading screen's clear condition holds on [`WarmPass::satisfied`] until the pipeline cache //! drains (10 s backstop, 0737's rule), then the menagerie despawns. Captures skip it. +//! Booth twins ride a real booth's layer (samples=1, 0938) AND the pass's own twin booth +//! ([`crate::portrait::spawn_warm_booth`] — the custom-projection view key real bakes install; +//! decision 0958), and [`warm_effect_lane`] pushes the `wow_effect` lane's whole key cross +//! through the production stream each warm frame (the ring's first-target stall, 0958). //! - [`PipeWatch`] — an `Arc` shared by the main and render worlds: how many pipelines the cache //! has ever queued, how many have settled (Ok/Err), and whether a cover currently hides the //! frame (loading screen up, or not in world — the glue scene is its own cover, 0540). @@ -42,6 +46,9 @@ use bevy::render::{Render, RenderApp, RenderSystems}; use crate::char_select::ClientState; use crate::loading_screen::LoadingScreen; use crate::model_render::MaterialCache; +use crate::particles::buffer::{ + begin_effect_frame, EffectBlend, EffectDrawSpec, EffectFog, EffectQuads, EffectVertex, +}; use crate::terrain::WowModelMaterial; mod menagerie; @@ -76,6 +83,14 @@ pub(crate) fn plugin(app: &mut App) { Update, run_warm_pass.before(crate::schedule::WorldStage::Present), ); + // The effect-lane warm writer rides the production stream, which is cleared at the top of + // PostUpdate's effect set — so it writes after the clear, like every family writer. The + // HUD-quad warm rides the UI append lane the same way (cleared at the top of its own set). + app.add_systems(PostUpdate, warm_effect_lane.after(begin_effect_frame)); + app.add_systems( + Update, + warm_ui_quad_lane.in_set(crate::ui_pass::UiQuadAppend), + ); let Some(render_app) = app.get_sub_app_mut(RenderApp) else { return; }; @@ -219,6 +234,12 @@ fn describe(desc: &PipelineDescriptor) -> String { #[derive(Component)] struct WarmRig; +/// Marker on the menagerie's twin booth camera ([`crate::portrait::spawn_warm_booth`] — the +/// custom-projection view key space, decision 0958), so [`warm_effect_lane`] can address its +/// view. It also carries [`WarmRig`], which despawns it with the rest of the pass. +#[derive(Component)] +struct WarmBoothCam; + /// Main-world warm-pass state. The loading screen folds [`Self::satisfied`] into its clear /// condition, so the cover holds while menagerie pipelines are still compiling. #[derive(Resource, Default)] @@ -228,6 +249,9 @@ pub(crate) struct WarmPass { spawned_at: Option, /// This cover's warm work is done (drained, timed out, or not applicable). done: bool, + /// The 1×1 stand-in texture [`warm_effect_lane`]'s draws bind while the pass runs (a strong + /// handle so the asset lives exactly as long as the pass; `None` = the lane isn't warming). + effect_tex: Option>, } impl WarmPass { @@ -268,6 +292,7 @@ fn run_warm_pass( // despawns. `done` stays true so the gate never blocks an uncovered frame. warm.done = true; warm.spawned_at = None; + warm.effect_tex = None; for e in &rigs { commands.entity(e).despawn(); } @@ -289,10 +314,20 @@ fn run_warm_pass( return; }; warm.spawned_at = Some(now); + // The twin booth (0958): the custom-projection view key space real bakes use — the real + // booths warm the placeholder-Perspective class, this camera the NONSTANDARD one. It is + // a WarmRig, so every despawn path below cleans it up with the rigs. + let warm_booth = crate::portrait::spawn_warm_booth(&mut commands, &mut lanes.images); + commands + .entity(warm_booth.0) + .insert((WarmRig, WarmBoothCam)); + // The effect lane's stand-in texture — held for the life of the pass. + warm.effect_tex = Some(lanes.images.add(Image::default())); let count = spawn_menagerie( &mut commands, cam, booth.iter().next(), + &warm_booth, &mut meshes, &mut materials, &mut lanes, @@ -321,15 +356,98 @@ fn run_warm_pass( .saturating_sub(watch.0.settled.load(Ordering::Relaxed)); if now - spawned >= WARM_SETTLE_SECS && pending == 0 { warm.done = true; + warm.effect_tex = None; for e in &rigs { commands.entity(e).despawn(); } info!("pipeline warm: drained in {:.2}s", now - spawned); } else if now - spawned >= WARM_TIMEOUT_SECS { warm.done = true; + warm.effect_tex = None; for e in &rigs { commands.entity(e).despawn(); } warn!("pipeline warm: TIMED OUT with {pending} pipelines pending — cover released"); } } + +/// The **effect-lane** warm writer (decision 0958 — the 07:45 log's [831], the selection ring's +/// first-target stall). `wow_effect` is a custom `SpecializedRenderPipeline` lane, not a +/// `MaterialPlugin` one, so no menagerie *entity* can reach it: its pipelines exist only when a +/// draw record sits in the shared stream at queue time. So while the pass runs, this pushes one +/// degenerate draw per reachable [`EffectPipelineKey`] — the full blend × raster-bias cross +/// ({Add, Alpha, Opaque, Multiply, Mod2x} × {0, ground-decal, blob-shadow}; the key's own doc +/// pins the closed bias set) — through the PRODUCTION stream (`EffectQuads` → extract → queue → +/// specialize), once per view class: the world camera (samples=N) and the twin booth (samples=1). +/// The queue path specializes per matching view regardless of coverage, so a 4-vertex sliver at +/// the origin compiles the whole space behind the cover; the stand-in texture keeps the prepare +/// half exercised too. Per-frame like the gizmo line: the stream clears every frame. +/// +/// [`EffectPipelineKey`]: crate::particles::render::EffectPipelineKey +/// The HUD-substrate warm (0958's sweep, residual): `UiQuadMaterial` is exactly ONE pipeline, +/// and on a normal entry it compiles covered because the HUD's first quad batch lands under the +/// cover — but nothing structural holds that timing (a slow Interface load would land it after +/// the lift). One invisible overlay quad per warm frame pins the compile inside the cover +/// window; the append lane clears itself every frame, so nothing lingers. +fn warm_ui_quad_lane(warm: Res, mut quads: ResMut) { + if warm.spawned_at.is_none() || warm.done { + return; + } + quads.overlays.push(crate::ui_pass::UiQuad { + rect: Rect::new(0.0, 0.0, 1.0, 1.0), + color: [0.0, 0.0, 0.0, 0.0], + ..default() + }); +} + +fn warm_effect_lane( + warm: Res, + mut quads: ResMut, + world_cam: Query>, + warm_booth: Query>, +) { + if warm.spawned_at.is_none() || warm.done { + return; + } + let Some(tex) = warm.effect_tex.as_ref() else { + return; + }; + for cam in world_cam.iter().chain(warm_booth.iter()) { + for blend in [ + EffectBlend::Add, + EffectBlend::Alpha, + EffectBlend::Opaque, + EffectBlend::Multiply, + EffectBlend::Mod2x, + ] { + for raster_bias in [ + 0, + crate::ground_fx::GROUND_FX_DEPTH_BIAS as i32, + crate::blob_shadow::SHADOW_RASTER_BIAS, + ] { + let start = quads.begin(); + for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] { + quads.verts.push(EffectVertex { + pos: [u * 0.01, v * 0.01, 0.0], + uv: [u, v], + color: [1.0, 1.0, 1.0, 1.0], + }); + } + quads.commit_quads( + start, + EffectDrawSpec { + cam, + texture: tex.id(), + blend, + fog: EffectFog::Off, + anchor: Vec3::ZERO, + bias: 0.0, + raster_bias, + main_entity: cam, + light: None, + }, + ); + } + } + } +} diff --git a/crates/benilla/src/portrait/glue_booth.rs b/crates/benilla/src/portrait/glue_booth.rs index d7a41f5c..f32a9340 100644 --- a/crates/benilla/src/portrait/glue_booth.rs +++ b/crates/benilla/src/portrait/glue_booth.rs @@ -386,7 +386,9 @@ pub(super) fn spawn_glue_booth( .spawn((Transform::IDENTITY, Visibility::Visible, layer.clone())) .id(); commands.spawn(( - Camera3d::default(), + // The shared booth view shape (HDR, no tonemap, no MSAA) — its doc in [`super`] says why + // every booth camera must spawn through it. + super::booth_view_shape(), Camera { order: -100 + GLUE_LAYER as isize, // Transparent (unlike the portrait/paper-doll booths' backdrop): the create screen @@ -396,10 +398,7 @@ pub(super) fn spawn_glue_booth( ..default() }, bevy::camera::RenderTarget::Image(image.clone().into()), - bevy::render::view::Hdr, - bevy::core_pipeline::tonemapping::Tonemapping::None, crate::ffx_glow::FfxGlow::WORLD, - Msaa::Off, // Placeholder — `sync_glue_booth` overwrites transform + projection from the model's bounds // on the first bake (the same `body_frame` law as the paper doll). Projection::from(bevy::camera::PerspectiveProjection { diff --git a/crates/benilla/src/portrait/mod.rs b/crates/benilla/src/portrait/mod.rs index 08f99117..125fb4dd 100644 --- a/crates/benilla/src/portrait/mod.rs +++ b/crates/benilla/src/portrait/mod.rs @@ -125,13 +125,18 @@ const INSPECT_LAYER: usize = PAPERDOLL_LAYER + 1; /// glue scene's 28 emitters addressed the INSPECT camera, which is off at the glue screens, and the /// login screen's braziers simulated forever without ever being drawn (decision 0775). pub(super) const GLUE_LAYER: usize = INSPECT_LAYER + 1; +/// The pipe_warm **twin booth**'s render layer — the next one past the glue booth's (same ladder +/// rule as [`GLUE_LAYER`]). This camera exists only while the warm pass runs +/// ([`spawn_warm_booth`]); nothing but menagerie rigs ever rides its layer. +pub(crate) const WARM_BOOTH_LAYER: usize = GLUE_LAYER + 1; // The ladder must stay collision-free: a booth camera's layer is its identity for both rendering // and the emitter→camera match, and the failure above was silent in both. const _: () = assert!( PAPERDOLL_LAYER != INSPECT_LAYER && INSPECT_LAYER != GLUE_LAYER - && PAPERDOLL_LAYER != GLUE_LAYER, + && PAPERDOLL_LAYER != GLUE_LAYER + && WARM_BOOTH_LAYER > GLUE_LAYER, "booth render layers must be distinct — see GLUE_LAYER" ); const _: () = assert!( @@ -578,6 +583,60 @@ fn new_target_image(size: u32) -> Image { image } +/// The booth **view shape** — the components that set a booth camera's *pipeline key space*: +/// HDR float intermediate + `Tonemapping::None` (the world camera's exact shape — the measured +/// whys sit on the portrait-slot spawn below) at `Msaa::Off`. Defined ONCE and spawned by every +/// booth camera — the portrait slots, the two body panes, the glue booth, and pipe_warm's twin +/// booth — because the warm pass compiles the samples=1 twin of every model pipeline against +/// exactly this shape behind the loading cover (decisions 0938/0958): a booth camera whose shape +/// drifts from the warm booth's is a live pipeline stall on its first bake. +fn booth_view_shape() -> impl Bundle { + ( + Camera3d::default(), + bevy::render::view::Hdr, + Tonemapping::None, + Msaa::Off, + ) +} + +/// The pipe_warm **twin booth** (decision 0958). The real booths spawn with a +/// `PerspectiveProjection` placeholder and render the menagerie's twins under bevy_pbr's +/// PERSPECTIVE view key — but nearly every real bake installs +/// `Projection::custom(WowPortraitProjection)` ([`frame`]'s authored path; [`body_frame`] +/// always), which is the NONSTANDARD view key: a distinct pipeline per variant, so the whole +/// samples=1 twin space compiled AGAIN, live, on the first target/paperdoll bake after entry. +/// This camera is that missing key space: the booth view shape exactly, with the custom +/// projection class real bakes use. pipe_warm spawns it beside the menagerie and despawns it at +/// drain. A REAL booth's projection is deliberately never stamped for warming — a bake completing +/// during the warm window would keep a misframed image. (The placeholder-Perspective class stays +/// warmed through a real booth: [`frame`]'s camera-less fallback still bakes with it.) +pub(crate) fn spawn_warm_booth( + commands: &mut Commands, + images: &mut Assets, +) -> (Entity, RenderLayers) { + let layer = RenderLayers::layer(WARM_BOOTH_LAYER); + let image = images.add(new_target_image(PORTRAIT_SIZE)); + let cam = commands + .spawn(( + booth_view_shape(), + Camera { + order: -100 + WARM_BOOTH_LAYER as isize, + clear_color: ClearColorConfig::Custom(Color::srgb(0.055, 0.045, 0.04)), + ..default() + }, + RenderTarget::Image(image.into()), + crate::ffx_glow::FfxGlow::WORLD, + Projection::custom(framing::WowPortraitProjection { + fov: framing::BODY_FOV, + near: 0.02, + far: 100.0, + }), + layer.clone(), + )) + .id(); + (cam, layer) +} + /// Startup: stand up one booth per slot — its image (registered in [`PortraitImages`]), a model-root /// entity, and a camera rendering only that slot's layer into the image (transparent, no bloom/MSAA, /// rendered before the world camera via a negative order). @@ -637,7 +696,9 @@ fn setup_booths( .spawn((Transform::IDENTITY, Visibility::Visible, layer.clone())) .id(); commands.spawn(( - Camera3d::default(), + // The shared booth view shape — HDR intermediate, no tonemap, no MSAA (its doc has + // the whys); per-slot here: the order, the backdrop, the glow node, the projection. + booth_view_shape(), Camera { // Render the booths first (negative order), so the freshly baked image is ready when // the world + UI cameras run. One order per slot to keep them distinct. @@ -649,19 +710,11 @@ fn setup_booths( }, // The render target is its own component in Bevy 0.18 (Camera `#[require]`s it). RenderTarget::Image(image.clone().into()), - // HDR float intermediate + Tonemapping::None — the world camera's exact pipeline shape. - // Measured without it: the LDR path stored the shader's linear output RAW in the sRGB - // target (one net extra decode when the UI sampled it — the bake read x^2.4 dark, pixel- - // verified against the clear color, which round-tripped fine). The HDR path encodes once - // at the final blit, like the main view. - bevy::render::view::Hdr, - Tonemapping::None, // The gamma lane (0161): booth materials emit gamma bytes like the world's, so the // booth needs the same final node — the FFXGlow combine owns the frame's ONE decode. // This also keeps the bake at exact world parity (same glow, same transform chain); // without it the portrait reads one encode too bright. crate::ffx_glow::FfxGlow::WORLD, - Msaa::Off, Projection::from(PerspectiveProjection { fov: PORTRAIT_FOV, near: 0.02, @@ -707,7 +760,7 @@ fn setup_booths( .spawn((Transform::IDENTITY, Visibility::Visible, layer.clone())) .id(); commands.spawn(( - Camera3d::default(), + booth_view_shape(), Camera { order: -100 + (SLOTS.len() + i) as isize, // A near-black backdrop like the portraits (the pipeline is proven opaque; the pane @@ -717,13 +770,10 @@ fn setup_booths( ..default() }, RenderTarget::Image(image.clone().into()), - bevy::render::view::Hdr, - Tonemapping::None, // Decode, but NO glow: these two stand in for 1.12 `` widgets, which the // reference paints in the UI strata — after the WorldFrame's own FFX apply — so they // never carry the scene glow (decision 0638; [`crate::ffx_glow::FfxGlow::UI_PANE`]). crate::ffx_glow::FfxGlow::UI_PANE, - Msaa::Off, // Placeholder — `sync_body_booth` overwrites transform + projection from the unit's // bounds on the first bake. A plain perspective is harmless while the model is loading. Projection::from(PerspectiveProjection { diff --git a/crates/benilla/src/sound/mod.rs b/crates/benilla/src/sound/mod.rs index 87224d64..4a2ace52 100644 --- a/crates/benilla/src/sound/mod.rs +++ b/crates/benilla/src/sound/mod.rs @@ -51,7 +51,11 @@ pub(crate) use zone::ExplorationSounds; /// **`AmbienceVolume` 0.6** — a fresh 1.12 install is NOT uniform full volume. #[derive(Resource)] pub(crate) struct SoundConfig { - /// Master enable — mirrors the client's `-nosound` gate as a live toggle. + /// Master enable — 1.12's `MasterSoundEffects` CVar, the Sound options "Enable All Sound" + /// checkbox (SoundOptionsFrame.lua index 1; registrar default "1", B10). In the binary its + /// callback sets the engine-wide pause flag (`0x457500` → `0x7a6570` → `DAT_0087cf00`); + /// benilla zeroes every category through [`Self::category_amp`] instead — channels keep + /// running silently (the `muted` posture), same audible truth. pub enabled: bool, /// Quick mute — toggled by the dev chord + `M` (its plane is per-OS: decisions 0585, 0867). /// Zeroes the **main track only**: selection and channel life go on untouched, so unmute is @@ -66,6 +70,14 @@ pub(crate) struct SoundConfig { pub sfx: f32, pub music: f32, pub ambience: f32, + /// The per-category enable checkboxes — 1.12's own `EnableMusic` / `EnableAmbience` CVars + /// (registrar defaults "1", wow-re B10; 1.12 has NO SFX-only toggle — `MasterSoundEffects` + /// above is the master). Gated in [`Self::category_amp`], so a disable silences the category + /// everywhere at once. Divergence, disclosed: the reference's `EnableMusic` callback + /// stops/re-selects the music stream (`0x457490` → `0x45b050`/`0x45aeb0`) — benilla keeps + /// the stream alive at zero, so re-enabling resumes mid-track where the reference re-picks. + pub music_enabled: bool, + pub ambience_enabled: bool, } impl SoundConfig { @@ -76,8 +88,9 @@ impl SoundConfig { } match cat { kit::SoundCategory::Sfx => self.sfx, - kit::SoundCategory::Music => self.music, - kit::SoundCategory::Ambience => self.ambience, + kit::SoundCategory::Music if self.music_enabled => self.music, + kit::SoundCategory::Ambience if self.ambience_enabled => self.ambience, + _ => 0.0, } } } @@ -91,6 +104,8 @@ impl Default for SoundConfig { sfx: 1.0, music: 0.4, ambience: 0.6, + music_enabled: true, + ambience_enabled: true, } } } diff --git a/crates/benilla/src/terrain_stream.rs b/crates/benilla/src/terrain_stream.rs index 957568a8..7fe56912 100644 --- a/crates/benilla/src/terrain_stream.rs +++ b/crates/benilla/src/terrain_stream.rs @@ -22,6 +22,7 @@ use benilla_assets::{AdtTile, M2Model, WdtIndex, WmoModel}; use benilla_formats::{world_to_tile, Doodad, WmoInstance}; use bevy::pbr::ExtendedMaterial; use bevy::prelude::*; +use bevy::render::render_resource::Face; use crate::assets::RenderConfig; use crate::char_select::{ClientState, Roster}; @@ -922,13 +923,28 @@ fn release_placement( } } -/// The terrain base material (a copy of the streamer's — the `TerrainExtension` does the real work). +/// The terrain base material (the `TerrainExtension` does the real work). +/// +/// **Single-sided, backface-culled** — the ground is see-through from underneath, exactly as the +/// real client renders it (B193: testers see through 1.12.1's terrain from below, the way a WMO +/// exterior reads from inside a cave; we drew it solid). The reference never sets `EGxRs 0x14` +/// (`GL_CULL_FACE`) in its terrain-chunk pass `0x684510`/`0x6beb50`, so terrain inherits the device +/// baseline `0x14 = 1` written by `0x593bf0` — culling ON. Only the passes that *want* two-sided +/// geometry clear it inside their own `Push`/`PopRenderState` bracket: the four liquid passes, and +/// the WDL low-detail tile render `0x6bd780` (cull 0 at `0x6bd79d`) — which is why `wdl.rs` stays +/// `cull_mode: None` while this one culls. `double_sided: true` here was a Bevy default that no +/// decision ever stood behind. +/// +/// Rests on the winding: `terrain_fans_wind_ccw_seen_from_above` (benilla-formats) pins every MCNK +/// fan CCW-from-above in WoW space, and `transform_is_a_proper_rotation` pins the WoW→Bevy map at +/// det +1 — so the ground's front face points up under wgpu's `FrontFace::Ccw`. Flip either and +/// terrain disappears from *above*. fn terrain_base_material() -> StandardMaterial { StandardMaterial { base_color: Color::WHITE, perceptual_roughness: 1.0, - double_sided: true, - cull_mode: None, + double_sided: false, + cull_mode: Some(Face::Back), ..default() } } diff --git a/crates/benilla/src/ui_script/options_tests.rs b/crates/benilla/src/ui_script/options_tests.rs index 6a242611..671e5acc 100644 --- a/crates/benilla/src/ui_script/options_tests.rs +++ b/crates/benilla/src/ui_script/options_tests.rs @@ -1,5 +1,5 @@ -//! The shipped `assets/ui/OptionsFrame.xml` — the era-skinned options window (decision 0950), -//! slice A: the shell. +//! The shipped `assets/ui/OptionsFrame.xml` — the era-skinned options window (decision 0950: +//! the shell; 0957: the Audio page; 0959: the Graphics page). //! //! What these guard: the file loads clean inside the real neighbourhood (Fonts + UiPanels + //! GameMenuFrame); the menu's Options button is the door in (menu down, options up, on the ref's @@ -9,6 +9,12 @@ //! when the selection moves. The harness pushes NO atlas table by default: `SetAtlas` is a //! warn-once no-op there by design (a missing extraction must not kill the UI), which is exactly //! why the one atlas-backed test pushes its own two members. +//! +//! The page tests (0957 Audio, 0959 Graphics) run against the REAL registered CVar set +//! (`crate::cvars`): rows read the table on select, writes land on the change queue the host +//! drains, the snap grids and readouts hold (the era 5% volumes; the 1.12 uiscale 0.01 and +//! farclip min-anchored 60), the 1.12 master→ambience dependency greys, and Defaults walks the +//! visible page back to the registered defaults. use benilla_ui::script::{EraAtlasEntry, SoundRequest, UiScript}; @@ -226,3 +232,346 @@ fn the_selected_row_wears_the_active_atlas_and_yields_it_on_reselect() { ); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } + +/// The Audio harness: the real registered CVar set on the table before the XML loads, exactly +/// the app's boot order (register → seed → load → select). +fn audio_harness() -> UiScript { + let mut s = UiScript::new().unwrap(); + s.register_cvars(crate::cvars::REGISTERED.iter().copied()); + s +} + +/// Selecting Audio shows the page body, arms Defaults, and every row reads the CVar table: +/// sliders take the stored value with the era rounded-percent readout, checkboxes take the flag. +/// Leaving the page hides it and puts Defaults back to sleep. +#[test] +fn the_audio_page_reads_the_cvar_table_on_select() { + let mut s = audio_harness(); + s.set_cvar_host("MusicVolume", "0.7"); + s.set_cvar_host("EnableMusic", "0"); + let s = harness_on(s); + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap(); + + assert!(s + .eval::("return OptionsFrameContainerBodyAudio:IsVisible()") + .unwrap()); + assert!(s + .eval::("return OptionsFrameContainerDefaults:IsEnabled()") + .unwrap()); + // The music slider holds the stored 0.7 (f32 wobble tolerated), readout "70%". + assert!(s + .eval::( + "return math.abs(OptionsFrameContainerBodyAudioRowMusicControlSlider:GetValue() - 0.7) < 0.0001" + ) + .unwrap()); + assert_eq!( + s.eval::("return OptionsFrameContainerBodyAudioRowMusicControlValue:GetText()") + .unwrap(), + "70%" + ); + // Checkboxes: EnableMusic off, the master (default "1") on. + assert!(!s + .eval::("return OptionsFrameContainerBodyAudioRowEnableMusicCheck:GetChecked()") + .unwrap()); + assert!(s + .eval::("return OptionsFrameContainerBodyAudioRowEnableAllCheck:GetChecked()") + .unwrap()); + + // Off to a ROWLESS page: body hidden, Defaults disabled again. + s.run("OptionsFrameCategoryListRowControls:Click()") + .unwrap(); + assert!(!s + .eval::("return OptionsFrameContainerBodyAudio:IsVisible()") + .unwrap()); + assert!(!s + .eval::("return OptionsFrameContainerDefaults:IsEnabled()") + .unwrap()); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// A user move snaps to the era 5% grid (obeyStepOnDrag transcribed) and writes the CVar as a +/// clean short string — the change queue carries what config.toml will store. A refresh write +/// (the page reading the table) queues nothing. +#[test] +fn a_slider_move_snaps_and_writes_the_cvar() { + let mut s = harness_on(audio_harness()); + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap(); + assert!( + s.take_cvar_changes().is_empty(), + "reading the table on select must not write it back" + ); + + // An off-grid move (what a drag delivers) snaps to 0.45 and queues exactly that. + s.run("OptionsFrameContainerBodyAudioRowMasterControlSlider:SetValue(0.43)") + .unwrap(); + assert!(s + .eval::( + "return math.abs(OptionsFrameContainerBodyAudioRowMasterControlSlider:GetValue() - 0.45) < 0.0001" + ) + .unwrap()); + assert_eq!( + s.eval::("return OptionsFrameContainerBodyAudioRowMasterControlValue:GetText()") + .unwrap(), + "45%" + ); + assert_eq!( + s.take_cvar_changes(), + vec![("MasterVolume".to_string(), "0.45".to_string())] + ); + + // The steppers move one step on the era's own kit sound. + let _ = s.take_sounds(); + s.run("OptionsFrameContainerBodyAudioRowMasterControlForward:Click()") + .unwrap(); + assert_eq!( + s.take_cvar_changes(), + vec![("MasterVolume".to_string(), "0.5".to_string())] + ); + assert!(s + .take_sounds() + .contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into()))); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// The checkbox rows write their flag CVar on the 1.12 panel's own (quirky) click kits, and the +/// 1.12 dependency holds: Enable All Sound off greys exactly the Enable Ambience row. +#[test] +fn the_checkbox_rows_write_flags_and_the_master_greys_ambience() { + let mut s = harness_on(audio_harness()); + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap(); + let _ = s.take_cvar_changes(); + let _ = s.take_sounds(); + + // Uncheck the master: flag "0" queued, ambience greyed, music left alive (the 1.12 quirk), + // and the just-UNchecked box plays the CheckBoxOn kit (SoundOptionsFrame.lua verbatim). + s.run("OptionsFrameContainerBodyAudioRowEnableAllCheck:Click()") + .unwrap(); + assert_eq!( + s.take_cvar_changes(), + vec![("MasterSoundEffects".to_string(), "0".to_string())] + ); + assert!(!s + .eval::("return OptionsFrameContainerBodyAudioRowEnableAmbienceCheck:IsEnabled()") + .unwrap()); + assert!(s + .eval::("return OptionsFrameContainerBodyAudioRowEnableMusicCheck:IsEnabled()") + .unwrap()); + assert!(s + .take_sounds() + .contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into()))); + + // Re-check: flag "1", ambience live again. + s.run("OptionsFrameContainerBodyAudioRowEnableAllCheck:Click()") + .unwrap(); + assert_eq!( + s.take_cvar_changes(), + vec![("MasterSoundEffects".to_string(), "1".to_string())] + ); + assert!(s + .eval::("return OptionsFrameContainerBodyAudioRowEnableAmbienceCheck:IsEnabled()") + .unwrap()); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// Defaults walks every Audio row's CVar back to its registered default and the rows follow — +/// the era per-page reset, on the one page with rows. +#[test] +fn defaults_resets_the_audio_page_to_registered_defaults() { + let mut s = audio_harness(); + s.set_cvar_host("MusicVolume", "0.9"); + s.set_cvar_host("EnableMusic", "0"); + let mut s = harness_on(s); + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap(); + let _ = s.take_cvar_changes(); + + s.run("OptionsFrameContainerDefaults:Click()").unwrap(); + let changes = s.take_cvar_changes(); + assert!( + changes.contains(&("MusicVolume".to_string(), "0.4".to_string())), + "music back to its 1.12 registration default: {changes:?}" + ); + assert!( + changes.contains(&("EnableMusic".to_string(), "1".to_string())), + "the flag back on: {changes:?}" + ); + // Only the MOVED values queue — the rows already at default write nothing. + assert_eq!(changes.len(), 2, "{changes:?}"); + assert!(s + .eval::("return OptionsFrameContainerBodyAudioRowEnableMusicCheck:GetChecked()") + .unwrap()); + assert_eq!( + s.eval::("return OptionsFrameContainerBodyAudioRowMusicControlValue:GetText()") + .unwrap(), + "40%" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// Selecting Graphics shows ITS page body (0959) with both 1.12 sliders reading the table: +/// uiScale on the 0.64..1.0 panel range with the percent readout, farclip on 177..777 with the +/// raw-yards readout. The swap works both ways — Audio's body takes over when clicked. +#[test] +fn the_graphics_page_reads_the_cvar_table_on_select() { + let mut s = audio_harness(); + s.set_cvar_host("uiScale", "0.8"); + s.set_cvar_host("farclip", "297"); + let s = harness_on(s); + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + s.run("OptionsFrameCategoryListRowGraphics:Click()") + .unwrap(); + + assert!(s + .eval::("return OptionsFrameContainerBodyGraphics:IsVisible()") + .unwrap()); + assert!(s + .eval::("return OptionsFrameContainerDefaults:IsEnabled()") + .unwrap()); + assert!(s + .eval::( + "return math.abs(OptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:GetValue() - 0.8) < 0.0001" + ) + .unwrap()); + assert_eq!( + s.eval::( + "return OptionsFrameContainerBodyGraphicsRowUiScaleControlValue:GetText()" + ) + .unwrap(), + "80%" + ); + assert!(s + .eval::( + "return math.abs(OptionsFrameContainerBodyGraphicsRowFarclipControlSlider:GetValue() - 297) < 0.001" + ) + .unwrap()); + assert_eq!( + s.eval::( + "return OptionsFrameContainerBodyGraphicsRowFarclipControlValue:GetText()" + ) + .unwrap(), + "297" + ); + // The labels are the 1.12 GlobalStrings' own. + assert_eq!( + s.eval::("return OptionsFrameContainerBodyGraphicsRowFarclipLabel:GetText()") + .unwrap(), + "Terrain Distance" + ); + + // The swap, the other way: Audio in, Graphics out. + s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap(); + assert!(!s + .eval::("return OptionsFrameContainerBodyGraphics:IsVisible()") + .unwrap()); + assert!(s + .eval::("return OptionsFrameContainerBodyAudio:IsVisible()") + .unwrap()); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// The Graphics sliders snap to the 1.12 panel grids and write clean short strings: farclip's +/// grid is 177+n*60 — ANCHORED AT THE MINIMUM, so 300 lands on 297, not the multiple-of-60 +/// 300 — and uiscale's is the 0.01 percent grid. Selecting the page queues nothing. +#[test] +fn the_graphics_sliders_snap_to_the_1_12_grids_and_write_clean_strings() { + let mut s = harness_on(audio_harness()); + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + s.run("OptionsFrameCategoryListRowGraphics:Click()") + .unwrap(); + assert!( + s.take_cvar_changes().is_empty(), + "reading the table on select must not write it back" + ); + + // farclip: a drag to 300 snaps DOWN the min-anchored grid to 297. + s.run("OptionsFrameContainerBodyGraphicsRowFarclipControlSlider:SetValue(300)") + .unwrap(); + assert!(s + .eval::( + "return math.abs(OptionsFrameContainerBodyGraphicsRowFarclipControlSlider:GetValue() - 297) < 0.001" + ) + .unwrap()); + assert_eq!( + s.eval::( + "return OptionsFrameContainerBodyGraphicsRowFarclipControlValue:GetText()" + ) + .unwrap(), + "297" + ); + assert_eq!( + s.take_cvar_changes(), + vec![("farclip".to_string(), "297".to_string())] + ); + + // The stepper walks one 60-yard step up the same grid, on the era kit. + let _ = s.take_sounds(); + s.run("OptionsFrameContainerBodyGraphicsRowFarclipControlForward:Click()") + .unwrap(); + assert_eq!( + s.take_cvar_changes(), + vec![("farclip".to_string(), "357".to_string())] + ); + assert!(s + .take_sounds() + .contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into()))); + + // uiScale: off-grid 0.787 snaps to 0.79, percent readout, two-decimal clean string. + s.run("OptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:SetValue(0.787)") + .unwrap(); + assert_eq!( + s.eval::( + "return OptionsFrameContainerBodyGraphicsRowUiScaleControlValue:GetText()" + ) + .unwrap(), + "79%" + ); + assert_eq!( + s.take_cvar_changes(), + vec![("uiScale".to_string(), "0.79".to_string())] + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} + +/// Defaults on the Graphics page: both CVars back to their registered defaults (uiScale 0.9, +/// farclip 777), rows following, and ONLY the moved values queue. +#[test] +fn defaults_resets_the_graphics_page_to_registered_defaults() { + let mut s = audio_harness(); + s.set_cvar_host("uiScale", "0.8"); + s.set_cvar_host("farclip", "297"); + let mut s = harness_on(s); + s.run("ShowUIPanel(OptionsFrame)").unwrap(); + s.run("OptionsFrameCategoryListRowGraphics:Click()") + .unwrap(); + let _ = s.take_cvar_changes(); + + s.run("OptionsFrameContainerDefaults:Click()").unwrap(); + let changes = s.take_cvar_changes(); + assert!( + changes.contains(&("uiScale".to_string(), "0.9".to_string())), + "{changes:?}" + ); + assert!( + changes.contains(&("farclip".to_string(), "777".to_string())), + "{changes:?}" + ); + assert_eq!(changes.len(), 2, "only the moved values queue: {changes:?}"); + assert_eq!( + s.eval::( + "return OptionsFrameContainerBodyGraphicsRowUiScaleControlValue:GetText()" + ) + .unwrap(), + "90%" + ); + assert_eq!( + s.eval::( + "return OptionsFrameContainerBodyGraphicsRowFarclipControlValue:GetText()" + ) + .unwrap(), + "777" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +}