diff --git a/README.md b/README.md index ead5c97f..021b2a08 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@
-

benilla

A from-scratch World of Warcraft 1.12.1 client in Rust and Bevy

diff --git a/crates/benilla-formats/examples/wmo_bsp.rs b/crates/benilla-formats/examples/wmo_bsp.rs new file mode 100644 index 00000000..7f5e4aec --- /dev/null +++ b/crates/benilla-formats/examples/wmo_bsp.rs @@ -0,0 +1,293 @@ +//! Which WMO faces the reference's collision BSP can actually **reach** — and which ones we collide +//! that it never tests: +//! `cargo run -p benilla-formats --example wmo_bsp -- ` +//! e.g. `wmo_bsp cavernsoftime`. +//! +//! The reference never brute-forces a WMO's triangles. Every collision query descends the group's +//! **MOBN** BSP and tests only the faces its leaves list in **MOBR**, then applies the per-face MOPY +//! reject mask (wow-re `collision.md` § "The WMO per-face MOPY collision mask": leaf `0x6bc700`, +//! *"a BSP-leaf face is skipped iff `(MOPY.flags & rejectMask) != 0`"* — walking `0x84`, camera +//! `0x82`). We parse no BSP at all: `accumulate_wmo_group_faces` walks **every** MOPY-passing +//! triangle. So the two agree only where MOBR happens to reference everything the mask keeps. +//! +//! This tool measures that gap per group — how many faces we collide that the reference's BSP cannot +//! reach, and where they are — which would be the difference between "solid wall" and "walk straight +//! through" at a WMO the file never asked to be solid. **Measured on CavernsOfTime (decision 0963): +//! the gap is ZERO across all 35 groups** — MOBR's leaves reach every non-DETAIL face, so our +//! brute-force set is exactly the reference's reachable set and the shortcut costs nothing but time. +//! Re-run it on any WMO whose collision is under suspicion before assuming the same. +//! +//! `inward` is the other half of the same question, for *rendering*: the share of a group's faces +//! whose winding normal points at the group's own centroid. A hollow room hull authored to be seen +//! from inside runs high (CoT's 34 interior groups: 59–100%), which is why the reference — single-sided since +//! MOMT `0x04` is clear — is see-through from outside, and why we were not until 0680. +//! +//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. + +use std::collections::HashSet; + +use benilla_wmo::{parse_wmo, ParsedWmo}; + +/// The walking gather's persistent MOPY reject bit (DETAIL/decal) — wow-re mask `0x84` minus the +/// transient `0x80` visited bit. +const MOPY_DETAIL: u8 = 0x04; + +fn main() -> anyhow::Result<()> { + let pat = std::env::args() + .nth(1) + .ok_or_else(|| anyhow::anyhow!("usage: wmo_bsp "))?; + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + let mut chain = benilla_formats::open_chain(&data)?; + let path = if pat.to_lowercase().ends_with(".wmo") { + pat.clone() + } else { + let lower = pat.to_lowercase(); + chain + .list()? + .into_iter() + .map(|e| e.name) + .find(|n| { + let l = n.to_lowercase(); + l.ends_with(".wmo") && l.contains(&lower) && !l.contains("_00") + }) + .ok_or_else(|| anyhow::anyhow!("no root .wmo matching {pat:?}"))? + }; + println!("{path}\n"); + + let 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"); + }; + let unculled = root + .materials + .iter() + .filter(|m| m.flags & 0x04 != 0) + .count(); + println!( + "=== MOMT: {unculled} of {} materials carry UNCULLED (0x04) ===", + root.materials.len() + ); + let stem = path.to_ascii_lowercase(); + let stem = stem.strip_suffix(".wmo").unwrap_or(&stem); + + println!("\n=== per group: what the BSP reaches vs what we collide ==="); + println!( + "{:>4} {:>10} {:>7} {:>8} {:>8} {:>9} {:>9} {:>6} gap (we−bsp)", + "grp", "mogpFlags", "faces", "MOBNn", "MOBRn", "bspFaces", "weCollide", "inward", + ); + let (mut tot_faces, mut tot_bsp, mut tot_we, mut tot_gap) = (0usize, 0usize, 0usize, 0usize); + for gi in 0..root.n_groups { + let group_path = format!("{stem}_{gi:03}.wmo"); + let Ok(gbytes) = chain.read_file(&group_path) else { + continue; + }; + let ParsedWmo::Group(group) = parse_wmo(&mut std::io::Cursor::new(&gbytes))? else { + continue; + }; + let n_faces = group.vertex_indices.len() / 3; + let nodes = mogp_subchunk(&gbytes, b"NBOM").unwrap_or(&[]); + let mobn = nodes.len() / 0x10; + let mobr: Vec = mogp_subchunk(&gbytes, b"RBOM") + .map(|c| { + c.chunks_exact(2) + .map(|b| u16::from_le_bytes([b[0], b[1]])) + .collect() + }) + .unwrap_or_default(); + // Reachability, not mere presence: a face listed in MOBR but sitting under a leaf the + // descent never enters is never tested. Walk MOBN from node 0 and union the leaves' spans. + let (bsp_reach, bad) = descend(nodes, &mobr); + // What the reference's walking query can ever hit: a face the BSP lists AND whose MOPY + // clears DETAIL. What we hit: every face whose MOPY clears DETAIL. + let (mut bsp_hit, mut we_hit, mut gap) = (0usize, 0usize, 0usize); + let mut gap_aabb = [[f32::MAX; 3], [f32::MIN; 3]]; + // Which way the shell presents. A hollow interior hull is authored to be seen from INSIDE: + // its winding normals point at the group's own centroid, so single-sided it is invisible + // (and see-through) from outside. Counted over every face, render or not. + let mut centroid = [0.0f64; 3]; + for p in &group.vertex_positions { + for (c, v) in [p.x, p.y, p.z].into_iter().enumerate() { + centroid[c] += f64::from(v); + } + } + let n_v = group.vertex_positions.len().max(1) as f64; + let centroid = centroid.map(|c| (c / n_v) as f32); + let mut inward = 0usize; + for f in 0..n_faces { + if let Some(n) = winding_normal(&group, f) { + let c = face_centroid(&group, f).unwrap_or([0.0; 3]); + let to_mid = [centroid[0] - c[0], centroid[1] - c[1], centroid[2] - c[2]]; + if n[0] * to_mid[0] + n[1] * to_mid[1] + n[2] * to_mid[2] > 0.0 { + inward += 1; + } + } + let flags = group.material_info.get(f).map_or(0, |m| m.flags); + if flags & MOPY_DETAIL != 0 { + continue; + } + we_hit += 1; + if bsp_reach.contains(&(f as u16)) { + bsp_hit += 1; + } else { + gap += 1; + for k in 0..3 { + let Some(&vi) = group.vertex_indices.get(f * 3 + k) else { + continue; + }; + let Some(p) = group.vertex_positions.get(vi as usize) else { + continue; + }; + for (c, v) in [p.x, p.y, p.z].into_iter().enumerate() { + gap_aabb[0][c] = gap_aabb[0][c].min(v); + gap_aabb[1][c] = gap_aabb[1][c].max(v); + } + } + } + } + tot_faces += n_faces; + tot_bsp += bsp_hit; + tot_we += we_hit; + tot_gap += gap; + let note = if gap > 0 { + format!( + "{gap:>6} aabb ({:.0},{:.0},{:.0})…({:.0},{:.0},{:.0})", + gap_aabb[0][0], + gap_aabb[0][1], + gap_aabb[0][2], + gap_aabb[1][0], + gap_aabb[1][1], + gap_aabb[1][2], + ) + } else { + " 0".to_string() + }; + println!( + "{gi:>4} {:>#10x} {n_faces:>7} {mobn:>8} {:>8} {bsp_hit:>9} {we_hit:>9} {:>6} {note}{}", + group.flags, + mobr.len(), + format!("{:.0}%", 100.0 * inward as f32 / n_faces.max(1) as f32), + if bad > 0 { + format!(" [!! {bad} malformed node refs — layout suspect]") + } else { + String::new() + }, + ); + } + println!( + "\ntotals: {tot_faces} faces · bsp-reachable+walkable {tot_bsp} · we collide {tot_we} · \ + gap {tot_gap}" + ); + Ok(()) +} + +/// A face's three positions, or `None` if any index is out of range. +fn face_verts(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[[f32; 3]; 3]> { + let mut out = [[0.0f32; 3]; 3]; + for (k, slot) in out.iter_mut().enumerate() { + let vi = *group.vertex_indices.get(f * 3 + k)? as usize; + let p = group.vertex_positions.get(vi)?; + *slot = [p.x, p.y, p.z]; + } + Some(out) +} + +/// The right-hand-rule normal of a face in its authored winding — the direction the face PRESENTS +/// under the reference's `GL_BACK`/CCW cull (and ours: `wow_to_bevy` is a proper rotation). +fn winding_normal(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[f32; 3]> { + let [a, b, c] = face_verts(group, f)?; + let (u, v) = ( + [b[0] - a[0], b[1] - a[1], b[2] - a[2]], + [c[0] - a[0], c[1] - a[1], c[2] - a[2]], + ); + Some([ + u[1] * v[2] - u[2] * v[1], + u[2] * v[0] - u[0] * v[2], + u[0] * v[1] - u[1] * v[0], + ]) +} + +/// A face's centroid. +fn face_centroid(group: &benilla_wmo::WmoGroup, f: usize) -> Option<[f32; 3]> { + let vs = face_verts(group, f)?; + let mut out = [0.0f32; 3]; + for (c, slot) in out.iter_mut().enumerate() { + *slot = (vs[0][c] + vs[1][c] + vs[2][c]) / 3.0; + } + Some(out) +} + +/// Walk the MOBN tree from node 0 and return the set of MOBR **face indices the descent can +/// actually reach**, plus a count of malformed references (out-of-range children or face spans). +/// +/// Node layout (wowdev, stride `0x10` — the stride itself is VERIFIED in wow-re +/// `models/scratch/re-pass.md`): `u16 flags · i16 negChild · i16 posChild · u16 nFaces · +/// u32 faceStart · f32 planeDist`. `flags & 0x4` = leaf. The `bad` counter is the falsifier: read +/// the layout wrong and children/spans go out of range immediately rather than quietly. +fn descend(nodes: &[u8], mobr: &[u16]) -> (HashSet, usize) { + let n = nodes.len() / 0x10; + let (mut seen, mut reach, mut bad) = (vec![false; n], HashSet::new(), 0usize); + let mut stack = if n > 0 { vec![0i16] } else { Vec::new() }; + while let Some(i) = stack.pop() { + let Ok(u) = usize::try_from(i) else { continue }; + if u >= n { + bad += 1; + continue; + } + if std::mem::replace(&mut seen[u], true) { + continue; + } + let b = &nodes[u * 0x10..u * 0x10 + 0x10]; + let rd16 = |o: usize| i16::from_le_bytes([b[o], b[o + 1]]); + let flags = u16::from_le_bytes([b[0], b[1]]); + if flags & 0x4 != 0 { + let n_faces = u16::from_le_bytes([b[6], b[7]]) as usize; + let start = u32::from_le_bytes([b[8], b[9], b[10], b[11]]) as usize; + match mobr.get(start..start + n_faces) { + Some(span) => reach.extend(span.iter().copied()), + None => bad += 1, + } + } else { + for c in [rd16(2), rd16(4)] { + if c >= 0 { + stack.push(c); + } + } + } + } + (reach, bad) +} + +/// Find a sub-chunk of a group file's MOGP super-chunk by on-disk (reversed) magic. +fn mogp_subchunk<'a>(group_bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a [u8]> { + let mogp = top_chunk(group_bytes, b"PGOM")?; + let mut off = 0x44usize; + while off + 8 <= mogp.len() { + let size = u32::from_le_bytes(mogp[off + 4..off + 8].try_into().ok()?) as usize; + let (start, end) = (off + 8, off + 8 + size); + if end > mogp.len() { + break; + } + if &mogp[off..off + 4] == magic { + return Some(&mogp[start..end]); + } + off = end; + } + None +} + +/// Find a top-level chunk by on-disk (reversed) magic. +fn top_chunk<'a>(bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a [u8]> { + let mut off = 0usize; + while off + 8 <= bytes.len() { + let size = u32::from_le_bytes(bytes[off + 4..off + 8].try_into().ok()?) as usize; + let (start, end) = (off + 8, off + 8 + size); + if end > bytes.len() { + break; + } + if &bytes[off..off + 4] == magic { + return Some(&bytes[start..end]); + } + off = end; + } + None +} diff --git a/crates/benilla-formats/examples/wmo_doubled.rs b/crates/benilla-formats/examples/wmo_doubled.rs index a6300683..14516aa1 100644 --- a/crates/benilla-formats/examples/wmo_doubled.rs +++ b/crates/benilla-formats/examples/wmo_doubled.rs @@ -12,9 +12,11 @@ //! Doubled-with-opposite-winding is not a defect — it is how 1.12 authors visible-from-both-sides //! cloth when the material is single-sided: the real client backface-culls each copy from the wrong //! side, so exactly one covers any pixel. It *becomes* a defect the moment the renderer draws WMO -//! batches two-sided (ours does, `group.rs` "keep two-sided for now"): both copies then rasterize at -//! depths equal to the ulp, and the winner per pixel is floating-point noise — latched while the -//! camera is bit-still, flickering while anything creeps. So each doubled pair is printed with its +//! batches two-sided — ours did until `3af8854f` honoured MOMT `0x04` (B38, decision 0680): both +//! copies then rasterize at depths equal to the ulp, and the winner per pixel is floating-point +//! noise — latched while the camera is bit-still, flickering while anything creeps. That is the +//! defect this tool was built to name, and it is now closed; it stays as the census. Each pair +//! prints with its //! batches' MOMT flags: `0x04` (UNCULLED) set means the file *asked* for two-sided and the double //! draw is authored intent; clear means the renderer's shortcut is manufacturing a z-tie the file //! never contained. diff --git a/crates/benilla-formats/src/bin/benilla-extract/chaincensus.rs b/crates/benilla-formats/src/bin/benilla-extract/chaincensus.rs index 56cc63f5..acd153b9 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/chaincensus.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/chaincensus.rs @@ -72,8 +72,8 @@ pub fn run(chain: &mut Chain) -> Result<()> { "halfWid", "noise", "scrollS", - "segDur", - "segDly", + "hopLife", + "hopStag", ); for id in 0..=255 { let Some(c) = visuals.chain_effect(id) else { @@ -85,8 +85,8 @@ pub fn run(chain: &mut Chain) -> Result<()> { c.half_width, c.noise_scale, c.scroll_period_s, - c.seg_duration_ms, - c.seg_delay_ms, + c.bolt_life_ms, + c.bolt_stagger_ms, c.texture, ); } diff --git a/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs b/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs index 5f0c221d..ee6a5fc7 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/spellvis.rs @@ -45,7 +45,7 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> { 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)", + " beam chain {} x{} flag={} -> {} (segLen {} halfWidth {} noise {} scroll {}s hopLife {}ms hopStagger {}ms)", c.effect_id, c.beams, u8::from(c.flag), @@ -54,8 +54,8 @@ pub fn run(chain: &mut Chain, spell_id: u32) -> Result<()> { e.half_width, e.noise_scale, e.scroll_period_s, - e.seg_duration_ms, - e.seg_delay_ms, + e.bolt_life_ms, + e.bolt_stagger_ms, ), None => println!( " beam chain {} (NO SUCH SpellChainEffects ROW)", diff --git a/crates/benilla-formats/src/models/wmo/group.rs b/crates/benilla-formats/src/models/wmo/group.rs index f5420ed9..926b502d 100644 --- a/crates/benilla-formats/src/models/wmo/group.rs +++ b/crates/benilla-formats/src/models/wmo/group.rs @@ -631,7 +631,7 @@ pub fn wmo_group_submeshes(group_bytes: &[u8], root: &WmoRoot) -> Result= -//! expiry`. So a **cast** beam (flag 0) self-terminates after `segmentCount × SegDuration`, and a +//! expiry`. So a **cast** beam (flag 0) self-terminates after `hopCount × BoltLife`, 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). //! @@ -138,13 +139,15 @@ pub struct ChainEffect { /// 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 5 — milliseconds. How long **one hop** burns (`Bolt.end = Bolt.start + this`), and + /// through it the whole beam's expiry, `now + hops × this`. Binds **only a one-shot (cast) + /// beam** — a channel beam ignores it and lives until swept. + pub bolt_life_ms: u32, + /// Field 6 — milliseconds. The **stagger between hops**: hop `i` lights at `t0 + i × this` + /// (`0x6ec9da`, its one consumer), so a 3-hop cast arcs outward instead of appearing whole. + /// `200` on most rows, `300` on id 1, `0` on the three rows no kit reaches. Irrelevant to a + /// single-hop beam, and to a channel beam (whose per-hop window is bypassed entirely). + pub bolt_stagger_ms: u32, /// Field 7 — the beam's texture, as the DBC stores it (`Textures\SpellChainEffects\*.blp`). pub texture: String, } @@ -176,8 +179,8 @@ fn chain_effects_schema() -> Schema { add("HalfWidth", FieldType::Float32); add("NoiseScale", FieldType::Float32); add("ScrollPeriod", FieldType::Float32); - add("SegDuration", FieldType::UInt32); - add("SegDelay", FieldType::UInt32); + add("BoltLife", FieldType::UInt32); + add("BoltStagger", FieldType::UInt32); add("Texture", FieldType::String); debug_assert_eq!(s.fields.len(), SPELL_CHAIN_EFFECTS_FIELDS); s @@ -201,8 +204,8 @@ pub(super) fn load(chain: &mut Chain) -> Result> { 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), + bolt_life_ms: u32_at(r, 5).unwrap_or(0), + bolt_stagger_ms: u32_at(r, 6).unwrap_or(0), texture: str_at(&set, r, 7).unwrap_or_default(), }, ); diff --git a/crates/benilla-formats/src/spell_visual/tests.rs b/crates/benilla-formats/src/spell_visual/tests.rs index 1da2981d..a070aece 100644 --- a/crates/benilla-formats/src/spell_visual/tests.rs +++ b/crates/benilla-formats/src/spell_visual/tests.rs @@ -500,8 +500,9 @@ fn char_proc_small_int_recovers_the_integer() { } /// `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). +/// director's report turns on — id 1 (Chain Lightning's `Lightning`, the one row whose hops +/// stagger by 300 ms rather than 200) and id 8 (Drain Life's `SoulBeam`, one of the four +/// negative-period drains). #[test] fn real_chain_effects_table() { let data = vanilla_data_dir(); @@ -526,14 +527,14 @@ fn real_chain_effects_table() { 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.bolt_life_ms, 1000); assert_eq!( - lightning.seg_delay_ms, 300, + lightning.bolt_stagger_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, + cat.chain_effect(4).expect("chain effect 4").bolt_stagger_ms, 200, "…and 200 is the table's norm" ); @@ -647,6 +648,37 @@ fn real_chain_procs_resolve_to_their_beams() { ); } +/// **Every live beam's texture actually exists on the patch chain.** The renderer (decision 0964) +/// loads these by path, and a path that resolves to nothing draws an invisible beam — a failure no +/// geometry test can see and no gate can catch. Cheapest possible guard against "the whole lane is +/// right and the screen is empty". +#[test] +fn real_chain_effect_textures_resolve_on_the_patch_chain() { + 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"); + // Only the rows a shipped kit can actually reach: an unreachable row's texture is nobody's + // problem (id 15 is degenerate anyway). + let mut reached: Vec = cat + .kit_ids() + .filter_map(|id| Some(cat.kit(id)?.chain_proc()?.effect_id)) + .collect(); + reached.sort_unstable(); + reached.dedup(); + assert!(!reached.is_empty(), "some kit must reach a beam"); + for id in reached { + let path = &cat.chain_effect(id).expect("its row").texture; + assert!( + chain.read_file(path).is_ok(), + "chain effect {id} names {path}, which does not exist on the patch chain" + ); + } +} + /// 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. diff --git a/crates/benilla-formats/tests/chain.rs b/crates/benilla-formats/tests/chain.rs index fe8120ae..8e50861a 100644 --- a/crates/benilla-formats/tests/chain.rs +++ b/crates/benilla-formats/tests/chain.rs @@ -357,13 +357,25 @@ 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. +/// `CULL_FACE = 1`, and the ground is see-through from underneath (decision 0960). Flip a fan's +/// winding and the world would vanish when viewed from *above* instead — a catastrophic, silent +/// regression 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. +/// Two forms of the same law: +/// +/// 1. **Exact.** 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 — independent of the authored +/// heights. (The reference builds its vertices the same way: `0x6b0e50` writes X and Y from the +/// grid indices and only Z from MCVT.) No shipped triangle may violate this. +/// 2. **Near-total.** wow-re's coordinate-system-free statement of the same convention: the emitted +/// winding is the one whose right-hand-rule normal agrees with the vertex's own MCNR normal. +/// Worth keeping because it survives a change of frame — but it is *not* exact on shipped data +/// and must not be asserted as if it were: wow-re derived it against flat ground, where MCNR is +/// exactly `(0,0,1)`, and on this Elwynn tile 4 of 196368 triangle-vertex pairs disagree (worst +/// cos −0.48, on a hillside). Those are authored outliers, not a decode fault — a decode fault +/// would be systematic, not 4-in-200k. A genuine winding flip turns ~every pair negative, which +/// the 0.01% ceiling below still catches loudly. #[test] fn terrain_fans_wind_ccw_seen_from_above() { let data = vanilla_data_dir(); @@ -379,16 +391,33 @@ fn terrain_fans_wind_ccw_seen_from_above() { benilla_formats::load_tile_mesh(&mut chain, "Azeroth", tx, ty).expect("mesh the tile"); let (mut tris, mut min_nz) = (0usize, f32::MAX); + let (mut pairs, mut disagreeing) = (0usize, 0usize); for chunk in &tile.chunks { + let shading = (chunk.normals.len() == chunk.positions.len()).then_some(&chunk.normals); 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], + let (i, j, k) = (t[0] as usize, t[1] as usize, t[2] as usize); + let (a, b, c) = (chunk.positions[i], chunk.positions[j], chunk.positions[k]); + let (u, v) = ( + [b[0] - a[0], b[1] - a[1], b[2] - a[2]], + [c[0] - a[0], c[1] - a[1], c[2] - a[2]], ); - // 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); + // Form 1 — Z of u×v: positive ⇔ CCW viewed down the +Z (up) axis. + min_nz = min_nz.min(u[0] * v[1] - u[1] * v[0]); + // Form 2 — does u×v agree with each of the three authored MCNR normals? + if let Some(normals) = shading { + let n = [ + u[1] * v[2] - u[2] * v[1], + u[2] * v[0] - u[0] * v[2], + u[0] * v[1] - u[1] * v[0], + ]; + for vert in [i, j, k] { + let s = normals[vert]; + pairs += 1; + if n[0] * s[0] + n[1] * s[1] + n[2] * s[2] <= 0.0 { + disagreeing += 1; + } + } + } tris += 1; } } @@ -398,6 +427,12 @@ fn terrain_fans_wind_ccw_seen_from_above() { "every terrain triangle must face up (CCW from above in WoW space); \ worst geometric-normal Z was {min_nz} over {tris} triangles" ); + assert!(pairs > 0, "Elwynn tile should carry MCNR normals"); + assert!( + disagreeing * 10_000 <= pairs, + "the emitted winding must agree with the authored MCNR normals bar a handful of outliers; \ + {disagreeing}/{pairs} disagreed (ceiling 0.01%) — a flipped fan turns ~all of them" + ); } #[test] diff --git a/crates/benilla/assets/ui/OptionsFrame.xml b/crates/benilla/assets/ui/OptionsFrame.xml index 02b5b654..6a5ee4b9 100644 --- a/crates/benilla/assets/ui/OptionsFrame.xml +++ b/crates/benilla/assets/ui/OptionsFrame.xml @@ -1,10 +1,12 @@ + + + + + + + + + + + + + OptionsRow_OnLoad(self, "deselectOnClick", "Sticky Targeting") + self.invert = 1 + + + + + + + + OptionsRow_OnLoad(self, "autoLootDefault", "Auto Loot") + + + + + + + OptionsRow_OnLoad(self, "mouseInvertPitch", "Invert Mouse") + + + + @@ -969,16 +1119,11 @@ 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") + -- era registers its uiscale row CommitFlag.Apply + -- (Graphics.lua l.684): the one row whose live + -- write would rescale the very slider mid-drag — + -- it stages and commits through the Apply button. + self.deferred = 1 @@ -1094,8 +1239,7 @@ - + + + + OptionsPage_DiscardPending() diff --git a/crates/benilla/src/creature_anim.rs b/crates/benilla/src/creature_anim.rs index aef38b1b..069b1969 100644 --- a/crates/benilla/src/creature_anim.rs +++ b/crates/benilla/src/creature_anim.rs @@ -511,8 +511,8 @@ use spell_visual::{ #[cfg(test)] pub(crate) use spell_visual::arm_aura_state_fx as arm_aura_state_fx_for_test; pub(crate) use spell_visual::{ - held_strike_sound, FxClass, FxStage, KitPush, MissileSpawn, SpellKitFx, SpellKitSound, - SpellVisuals, + held_strike_sound, ChainProcPlay, FxClass, FxStage, KitPush, MissileSpawn, SpellKitFx, + SpellKitSound, SpellVisuals, }; /// The per-unit animation state machine. @@ -804,6 +804,8 @@ impl Plugin for CreatureAnimPlugin { .add_message::() .add_message::() .add_message::() + // The kit's beam edge (0955) — `crate::entities` owns what it becomes. + .add_message::() .add_message::() // The aura CharProc edges the slot watcher emits alongside its effect-model ones — // drained by `crate::aura_visual`, which owns the body's alpha/tint for an aura's life. diff --git a/crates/benilla/src/creature_anim/spell_visual.rs b/crates/benilla/src/creature_anim/spell_visual.rs index e638dd97..5ff4f865 100644 --- a/crates/benilla/src/creature_anim/spell_visual.rs +++ b/crates/benilla/src/creature_anim/spell_visual.rs @@ -159,6 +159,21 @@ pub(crate) enum SpellKitFx { }, } +/// A kit whose `CharProc` slots name a **beam** just played on `entity` — the client's CharProc +/// dispatcher reaching its chain case (`0x60da79`, decision 0955). Written here for every kit play +/// that carries one (the dispatcher runs at `PlaySpellVisualKit`'s tail, `0x60f35c`, and again from +/// the channel poll, `0x612b18`); consumed by `crate::entities::chain_beam`, which owns the target +/// selection against the caster's hop array, the beam's lifetime, and its geometry. +#[derive(Message, Clone, Copy)] +pub(crate) struct ChainProcPlay { + /// The unit the kit played on — the beam's caster end, and the owner of the hop array. + pub(crate) entity: Entity, + /// The kit's spell (`0` for a bare kit push, which the client's own `spellId != 0` guard at + /// `0x6ecbd0` refuses to build a beam for). + pub(crate) spell_id: u32, + pub(crate) proc: benilla_formats::ChainProc, +} + /// Launch a projectile from `caster` at each target (decision 0099 phase 4) — one missile per /// target, the client's per-target spawn on the Speed>0 GO branch (`0x6e8a50 → 0x60a3d0`). /// Written here (the DBC columns resolve in this module, like every kit edge); consumed by @@ -347,11 +362,13 @@ pub(crate) struct KitPush { /// The writer set one discrete kit play fans out to — bundled so [`play_kit`] threads through the /// router's arms as one argument (each writer keeps its own system-param lifetime). -struct KitOut<'a, 'w1, 'w2, 'w3, 'w4> { +struct KitOut<'a, 'w1, 'w2, 'w3, 'w4, 'w5> { oneshots: &'a mut MessageWriter<'w1, EmoteAnim>, wounds: &'a mut MessageWriter<'w2, WoundAnim>, sounds: &'a mut MessageWriter<'w3, SpellKitSound>, fx: &'a mut MessageWriter<'w4, SpellKitFx>, + /// The kit's beam, if its `CharProc` slots name one — the dispatcher's chain case (0955). + chain: &'a mut MessageWriter<'w5, ChainProcPlay>, } /// One kit played as a **discrete event** on a unit — the client's `PlaySpellVisualKit` @@ -412,6 +429,17 @@ fn play_kit( if let Some(kit_sound) = kit.sound.filter(|_| play.sound) { out.sounds.write(SpellKitSound::Play { entity, kit_sound }); } + // The CharProc dispatcher's beam case — run for EVERY kit play, at every stage, exactly as + // `PlaySpellVisualKit`'s tail runs it (`0x60f35c`). It is not gated by `play.effects`: the + // beam is not one of the kit's attach-point effect models, and a kit whose caster carries no + // hop array simply draws nothing (`0x60db01`'s `count == 0` exit). + if let Some(proc) = kit.chain_proc() { + out.chain.write(ChainProcPlay { + entity, + spell_id, + proc, + }); + } if !play.effects { return; } @@ -495,12 +523,13 @@ pub(super) fn route_cast_visuals( mut sounds: MessageWriter, mut fx: MessageWriter, mut sheaths: MessageWriter, - // One tuple param (the 16-SystemParam ceiling): the missile spawns + the dest one-shot - // orders (0797) — the GO's two launch-side spawn lanes, resolved here where the catalogs - // live, spawned by `crate::entities`. + // One tuple param (the 16-SystemParam ceiling): the missile spawns, the dest one-shot orders + // (0797) and the beam plays (0955) — the spawn lanes resolved here where the catalogs live, + // built by `crate::entities`. mut spawns: ( MessageWriter, MessageWriter, + MessageWriter, ), visuals: Option>, spells: Option>, @@ -512,11 +541,15 @@ pub(super) fn route_cast_visuals( let (Some(visuals), Some(spells)) = (visuals.as_deref(), spells.as_deref()) else { return; // no client data — no spell visuals (every DBC resource degrades this way) }; + // Disjoint field borrows: the beam writer rides `out` for the whole body while the missile and + // burst writers stay free for the GO loop below. + let (missiles, bursts, chains) = (&mut spawns.0, &mut spawns.1, &mut spawns.2); let mut out = KitOut { oneshots: &mut oneshots, wounds: &mut wounds, sounds: &mut sounds, fx: &mut fx, + chain: chains, }; // The `holds` query is one command-flush stale: an instant cast's START and GO drain from @@ -770,7 +803,7 @@ pub(super) fn route_cast_visuals( if let Some(stages) = visuals.0.stages(display.visual) { if stages.missile_gate == 0 && stages.area_effect != 0 { if let Some(path) = visuals.0.effect_path(stages.area_effect) { - spawns.1.write(crate::entities::dest_fx::GroundBurst { + bursts.write(crate::entities::dest_fx::GroundBurst { path: path.to_string(), pos: dest, }); @@ -829,7 +862,7 @@ pub(super) fn route_cast_visuals( let awaits_release = resolve_kit(spells, &visuals.0, go.spell_id, |s| s.cast, || wv) .is_some_and(|k| k.anim_id.is_some()); - spawns.0.write(MissileSpawn { + missiles.write(MissileSpawn { caster: go.caster, spell_id: go.spell_id, path, @@ -885,6 +918,17 @@ pub(super) fn route_cast_visuals( if let Some(kit_sound) = kit.sound { out.sounds.write(SpellKitSound::Play { entity, kit_sound }); } + // The CharProc dispatcher's SECOND caller (`0x612b18`, inside this very poll + // `0x612a30`) — which is how a channelled beam exists at all: Drain Life's kit + // is never reached by `PlaySpellVisualKit`, only from here. Emitted on the + // channel's rising edge; the beam then lives until the field clears (0955). + if let Some(proc) = kit.chain_proc() { + out.chain.write(ChainProcPlay { + entity, + spell_id: cur, + proc, + }); + } // The channel kit's effect models — persistent while the field holds (the // client's stage-2 lifetime, and so the stage-2 Birth → Hold → Decay lifecycle: // the channel poll `0x612a30` is one of the caller table's stage-2 sites). diff --git a/crates/benilla/src/creature_anim/spell_visual/tests.rs b/crates/benilla/src/creature_anim/spell_visual/tests.rs index c2e4663b..e044dc5c 100644 --- a/crates/benilla/src/creature_anim/spell_visual/tests.rs +++ b/crates/benilla/src/creature_anim/spell_visual/tests.rs @@ -43,6 +43,7 @@ fn app() -> App { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::(); app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables( HashMap::from([ @@ -202,6 +203,7 @@ fn precast_kit_sound_rings_once_at_start() { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::(); app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables( HashMap::from([( @@ -575,6 +577,7 @@ fn missile_spawn_defers_iff_the_cast_kit_animates() { .add_message::() .add_message::() .add_message::() + .add_message::() .add_message::(); app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables( HashMap::from([ @@ -822,3 +825,123 @@ fn the_mount_poof_puffs_on_the_build_leg_only() { app.update(); assert_eq!(puffs(&mut app), vec![(BASE_ATTACH, POOF.to_string())]); } + +/// **The link that makes the beam exist at all** (decision 0955 slice 2): a kit carrying a chain +/// `CharProc` emits a [`ChainProcPlay`] when it plays — from BOTH of the reference's dispatcher +/// call sites, `PlaySpellVisualKit`'s tail (the cast release) and the channel poll (`0x612b18`, +/// which is the only way a channelled beam is ever reached). A kit without one emits nothing. +/// +/// This is the test that fails if the whole lane silently does nothing: the geometry, the hop +/// array and the wire can all be right while no kit ever asks for a beam. +#[test] +fn a_kit_with_a_chain_char_proc_asks_for_a_beam_from_both_dispatcher_sites() { + use benilla_formats::{char_proc_type, CharProc}; + + const BEAM_SPELL: u32 = 421; // Chain Lightning + const BEAM_VISUAL: u32 = 36; + const BEAM_CAST_KIT: u32 = 321; + const BEAM_CHANNEL_KIT: u32 = 402; + + let mut app = app(); + // Overlay the beam chain onto the fixture catalog: a cast kit with the real type-12 slot and a + // channel kit with the real type-0 one. Params are the shipped shape — chain id 1, one strand, + // and the flag that splits cast (0) from channel (1). + let mut visuals = HashMap::from([( + BEAM_VISUAL, + VisualStages { + cast: BEAM_CAST_KIT, + channel: BEAM_CHANNEL_KIT, + ..Default::default() + }, + )]); + let mut kits = HashMap::new(); + for (kit, ty, flag) in [ + (BEAM_CAST_KIT, char_proc_type::CHAIN_CAST, 0.0), + (BEAM_CHANNEL_KIT, char_proc_type::CHAIN_CHANNEL, 1.0), + ] { + kits.insert( + kit, + VisualKit { + char_proc_slots: [ + Some(CharProc { + ty, + params: [1.0, 1.0, flag, 0.0], + }), + None, + None, + None, + ], + ..Default::default() + }, + ); + } + // …plus a beam-less kit, so "emits nothing" is a real control and not an empty catalog. + kits.insert(PRECAST_KIT, VisualKit::default()); + visuals.insert( + VISUAL, + VisualStages { + cast: PRECAST_KIT, + ..Default::default() + }, + ); + app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(visuals, kits))); + app.insert_resource(crate::ui_action::Spells { + catalog: SpellCatalog::from_displays(HashMap::from([ + ( + BEAM_SPELL, + SpellDisplay { + visual: BEAM_VISUAL, + ..Default::default() + }, + ), + ( + SPELL, + SpellDisplay { + visual: VISUAL, + ..Default::default() + }, + ), + ])), + ..crate::ui_action::Spells::empty_for_tests() + }); + + let plays = |app: &mut App| -> Vec<(Entity, u32, bool)> { + app.world_mut() + .resource_mut::>() + .drain() + .map(|p| (p.entity, p.spell_id, p.proc.flag)) + .collect() + }; + + // Site 1 — the cast release (`0x60f35c`). + let unit = app.world_mut().spawn_empty().id(); + app.world_mut() + .write_message(cast_event(unit, BEAM_SPELL, CastEventKind::Go)); + app.update(); + assert_eq!( + plays(&mut app), + vec![(unit, BEAM_SPELL, false)], + "the cast kit's type-12 proc asks for a one-shot beam" + ); + + // The control: a spell whose cast kit carries no chain proc asks for nothing. + app.world_mut() + .write_message(cast_event(unit, SPELL, CastEventKind::Go)); + app.update(); + assert!(plays(&mut app).is_empty(), "a beam-less kit stays silent"); + + // Site 2 — the channel poll (`0x612b18`). The rising edge of `UNIT_CHANNEL_SPELL` is what + // reaches Drain Life's kit; nothing else ever plays it. + let channeller = app + .world_mut() + .spawn(crate::net::ObjectStore( + benilla_protocol::ObjectFields::from_pairs(&[(144, BEAM_SPELL)]), + )) + .id(); + app.update(); + assert_eq!( + plays(&mut app), + vec![(channeller, BEAM_SPELL, true)], + "the channel kit's type-0 proc asks for a persistent beam" + ); +} diff --git a/crates/benilla/src/cursor.rs b/crates/benilla/src/cursor.rs index 0e6db0fd..c90458d7 100644 --- a/crates/benilla/src/cursor.rs +++ b/crates/benilla/src/cursor.rs @@ -106,6 +106,9 @@ const CURSOR_STEMS: &[&str] = &[ "UnableSpeak", "Pickup", "UnablePickup", + // The loot leg's triple pouch (0965): effective auto-loot on (0961's setting XOR shift). + "LootAll", + "UnableLootAll", "Interact", "UnableInteract", "Buy", diff --git a/crates/benilla/src/cvars.rs b/crates/benilla/src/cvars.rs index 7e52fc88..69715e9b 100644 --- a/crates/benilla/src/cvars.rs +++ b/crates/benilla/src/cvars.rs @@ -27,7 +27,10 @@ use std::time::Instant; use bevy::prelude::*; +use crate::player::camera::LookConfig; use crate::sound::SoundConfig; +use crate::target::ClickConfig; +use crate::ui_loot::LootConfig; use crate::ui_script::UiScaleCvar; use crate::view::{ViewDistance, FARCLIP_RANGE}; use benilla_ui::script::UiScript; @@ -48,6 +51,14 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[ ("EnableAmbience", "1"), ("uiScale", "0.9"), ("farclip", "777"), + // The Controls-page trio (0961). `deselectOnClick`/`mouseInvertPitch` are 1.12's own + // Interface Options CVars (UIOptionsFrame.lua indices 45/1); their defaults are the + // reference behaviors benilla already shipped (empty-world click clears the target; no + // pitch invert). `autoLootDefault` is era's — no 1.12 CVar exists, vanilla only had the + // shift gesture — default off, like era's engine registrar. + ("deselectOnClick", "1"), + ("mouseInvertPitch", "0"), + ("autoLootDefault", "0"), ]; /// `config.toml`'s shape: a `[cvars]` table of `Name = "value"` strings (CVars are strings in @@ -89,30 +100,38 @@ impl Plugin for CvarPlugin { } } +/// The knob resources one CVar write can land on, bundled so [`apply_to_knobs`] and its two +/// callers grow together (a new knob is one field + one arm). +struct Knobs<'a> { + sound: &'a mut SoundConfig, + scale: &'a mut UiScaleCvar, + view: &'a mut ViewDistance, + look: &'a mut LookConfig, + click: &'a mut ClickConfig, + loot: &'a mut LootConfig, +} + /// Apply one CVar to its knob resource (parse + the knob's own clamp). `false` = not a knob this /// build knows (the caller decides whether that warns or rides through). -fn apply_to_knobs( - name: &str, - value: &str, - sound: &mut SoundConfig, - scale: &mut UiScaleCvar, - view: &mut ViewDistance, -) -> bool { +fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool { let Ok(v) = value.parse::() else { warn!("cvar {name}: unparseable value '{value}' ignored"); return true; // known key, bad value — consumed, resource keeps its truth }; match name.to_ascii_lowercase().as_str() { - "mastervolume" => sound.master = v.clamp(0.0, 1.0), - "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), + "mastervolume" => knobs.sound.master = v.clamp(0.0, 1.0), + "soundvolume" => knobs.sound.sfx = v.clamp(0.0, 1.0), + "musicvolume" => knobs.sound.music = v.clamp(0.0, 1.0), + "ambiencevolume" => knobs.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()), + "mastersoundeffects" => knobs.sound.enabled = v != 0.0, + "enablemusic" => knobs.sound.music_enabled = v != 0.0, + "enableambience" => knobs.sound.ambience_enabled = v != 0.0, + "uiscale" => knobs.scale.0 = v.clamp(0.5, 1.5), + "farclip" => knobs.view.farclip = v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end()), + "deselectonclick" => knobs.click.deselect_on_click = v != 0.0, + "mouseinvertpitch" => knobs.look.invert_pitch = v != 0.0, + "autolootdefault" => knobs.loot.auto_loot = v != 0.0, _ => return false, } true @@ -127,7 +146,18 @@ fn load_config( mut sound: ResMut, mut scale: ResMut, mut view: ResMut, + mut look: ResMut, + mut click: ResMut, + mut loot: ResMut, ) { + let mut knobs = Knobs { + sound: &mut sound, + scale: &mut scale, + view: &mut view, + look: &mut look, + click: &mut click, + loot: &mut loot, + }; if std::env::var_os("WOW_UI_SCALE").is_some() { persist.env_overridden.insert("uiscale".into()); } @@ -171,7 +201,7 @@ fn load_config( info!("config: {name} overridden by env for this session (file value kept)"); continue; } - apply_to_knobs(name, value, &mut sound, &mut scale, &mut view); + apply_to_knobs(name, value, &mut knobs); } persist.file = cfg.cvars; } @@ -179,12 +209,16 @@ fn load_config( /// Per frame: seed the VM's table once it exists (registered set + the RESOLVED session values, /// so `GetCVar` reflects env overrides and the loaded config alike), then drain Lua `SetCVar` /// changes into the knob resources and mark the config dirty. +#[allow(clippy::too_many_arguments)] // one knob resource per registered CVar family fn sync_cvars( script: Option>, mut persist: ResMut, mut sound: ResMut, mut scale: ResMut, mut view: ResMut, + mut look: ResMut, + mut click: ResMut, + mut loot: ResMut, ) { let Some(mut script) = script else { return; @@ -192,7 +226,7 @@ fn sync_cvars( if !persist.registered { script.register_cvars(REGISTERED.iter().copied()); let flag = |b: bool| if b { "1" } else { "0" }.to_string(); - let session: [(&str, String); 9] = [ + let session: [(&str, String); 12] = [ ("MasterVolume", sound.master.to_string()), ("SoundVolume", sound.sfx.to_string()), ("MusicVolume", sound.music.to_string()), @@ -202,14 +236,25 @@ fn sync_cvars( ("EnableAmbience", flag(sound.ambience_enabled)), ("uiScale", scale.0.to_string()), ("farclip", view.farclip.to_string()), + ("deselectOnClick", flag(click.deselect_on_click)), + ("mouseInvertPitch", flag(look.invert_pitch)), + ("autoLootDefault", flag(loot.auto_loot)), ]; for (name, value) in session { script.set_cvar_host(name, &value); } persist.registered = true; } + let mut knobs = Knobs { + sound: &mut sound, + scale: &mut scale, + view: &mut view, + look: &mut look, + click: &mut click, + loot: &mut loot, + }; for (name, value) in script.take_cvar_changes() { - if apply_to_knobs(&name, &value, &mut sound, &mut scale, &mut view) { + if apply_to_knobs(&name, &value, &mut knobs) { persist.dirty = true; persist.last_change = Some(Instant::now()); } @@ -313,6 +358,16 @@ mod tests { // ViewDistance::default() reads $WOW_FARCLIP; the registered default mirrors the // env-less 777 literal (view.rs doc: "Default 777"). assert_eq!(d["farclip"], 777.0); + // The Controls trio (0961) welds to its knob Defaults the same way. + assert_eq!( + d["deselectOnClick"] != 0.0, + ClickConfig::default().deselect_on_click + ); + assert_eq!( + d["mouseInvertPitch"] != 0.0, + LookConfig::default().invert_pitch + ); + assert_eq!(d["autoLootDefault"] != 0.0, LootConfig::default().auto_loot); } #[test] @@ -320,52 +375,40 @@ mod tests { let mut sound = SoundConfig::default(); let mut scale = UiScaleCvar(0.9); let mut view = ViewDistance { farclip: 777.0 }; - assert!(apply_to_knobs( - "MusicVolume", - "0.7", - &mut sound, - &mut scale, - &mut view - )); - assert_eq!(sound.music, 0.7); + let mut look = LookConfig::default(); + let mut click = ClickConfig::default(); + let mut loot = LootConfig::default(); + let mut knobs = Knobs { + sound: &mut sound, + scale: &mut scale, + view: &mut view, + look: &mut look, + click: &mut click, + loot: &mut loot, + }; + assert!(apply_to_knobs("MusicVolume", "0.7", &mut knobs)); + assert_eq!(knobs.sound.music, 0.7); // Clamps are the knob's own: volume to [0,1], farclip to FARCLIP_RANGE. - assert!(apply_to_knobs( - "mastervolume", - "7", - &mut sound, - &mut scale, - &mut view - )); - assert_eq!(sound.master, 1.0); - assert!(apply_to_knobs( - "farclip", "50", &mut sound, &mut scale, &mut view - )); - assert_eq!(view.farclip, *FARCLIP_RANGE.start()); + assert!(apply_to_knobs("mastervolume", "7", &mut knobs)); + assert_eq!(knobs.sound.master, 1.0); + assert!(apply_to_knobs("farclip", "50", &mut knobs)); + assert_eq!(knobs.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); + assert!(apply_to_knobs("EnableMusic", "0", &mut knobs)); + assert!(!knobs.sound.music_enabled); + assert!(apply_to_knobs("mastersoundeffects", "1", &mut knobs)); + assert!(knobs.sound.enabled); + // The Controls trio lands on its knobs (case-insensitive like everything else). + assert!(apply_to_knobs("deselectonclick", "0", &mut knobs)); + assert!(!knobs.click.deselect_on_click); + assert!(apply_to_knobs("MouseInvertPitch", "1", &mut knobs)); + assert!(knobs.look.invert_pitch); + assert!(apply_to_knobs("autoLootDefault", "1", &mut knobs)); + assert!(knobs.loot.auto_loot); // 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 - )); - assert_eq!(scale.0, 0.9); - assert!(!apply_to_knobs( - "bogus", "1", &mut sound, &mut scale, &mut view - )); + assert!(apply_to_knobs("uiScale", "banana", &mut knobs)); + assert_eq!(knobs.scale.0, 0.9); + assert!(!apply_to_knobs("bogus", "1", &mut knobs)); } #[test] @@ -417,6 +460,9 @@ mod tests { .insert_resource(SoundConfig::default()) .insert_resource(UiScaleCvar(DEFAULT_UI_SCALE)) .insert_resource(ViewDistance { farclip: 777.0 }) + .init_resource::() + .init_resource::() + .init_resource::() .add_plugins(CvarPlugin); app.insert_non_send_resource(UiScript::new().unwrap()); diff --git a/crates/benilla/src/debug_panel/mod.rs b/crates/benilla/src/debug_panel/mod.rs index e0cea3d3..95e1eadf 100644 --- a/crates/benilla/src/debug_panel/mod.rs +++ b/crates/benilla/src/debug_panel/mod.rs @@ -37,7 +37,6 @@ use bevy_egui::{ use benilla_formats::ModelBlend; use crate::lighting::{ClockSource, GameClock, WowLighting}; -use crate::particles::ParticleTuning; use crate::view::ViewDistance; /// The debug state — resource-only, faithful defaults (decision 0026: the always-present config @@ -347,7 +346,6 @@ fn debug_panel_ui( anim_hosts: Query<&crate::doodad_anim::DoodadAnimHost>, mat_anims: Query<&crate::doodad_anim::MatAnim>, uv_mats: Res, - mut particles: ResMut, mut sound_cfg: ResMut, mut cull_probe: ResMut, net_status: Res, @@ -413,7 +411,7 @@ fn debug_panel_ui( .show(ui, |ui| world_section(ui, &mut world)); egui::CollapsingHeader::new("Models") - .default_open(true) + .default_open(false) .show(ui, |ui| { ui.strong("Layer (blend mode)"); for i in 0..BLEND_LABELS.len() { @@ -483,7 +481,7 @@ fn debug_panel_ui( }); egui::CollapsingHeader::new("Lighting") - .default_open(true) + .default_open(false) .show(ui, |ui| { // Time of day — drives the DBC-sampled colors + the sun's day-arc. ui.strong(format!( @@ -599,21 +597,6 @@ fn debug_panel_ui( // The object inspector is its own dev-chord `I` surface (see `interact.rs`), not a // section here — identifying a thing shouldn't need the whole panel open. - egui::CollapsingHeader::new("Particles") - .default_open(true) - .show(ui, |ui| { - // The reference's own particleDensity CVar range — a real game - // setting (spawn rate only), not a tuning knob. No distance cull - // exists in the reference (wow-re part-distance-density.md). - // (The intensity/size/tint A/B sliders retired once the colour-space - // lane closed — 0150/0152; faithful 1.0 is the only path, the same - // way the glow-card knobs died with 0159.) - ui.add( - egui::Slider::new(&mut particles.density, 0.25..=1.0) - .text("particleDensity (CVar)"), - ); - }); - egui::CollapsingHeader::new("Sound") .default_open(false) .show(ui, |ui| { diff --git a/crates/benilla/src/entities.rs b/crates/benilla/src/entities.rs index 8277a086..dfa5d4aa 100644 --- a/crates/benilla/src/entities.rs +++ b/crates/benilla/src/entities.rs @@ -108,6 +108,13 @@ pub(crate) mod dest_fx; use dest_fx::{ arm_ground_effects, attach_ground_fx_models, spawn_ground_bursts, tick_shard_emitters, }; + +/// Spell **chain beams** (decision 0955): the polyline of hops a kit's chain `CharProc` draws — +/// Chain Lightning's arcs, Drain Life's rope of soul, C'Thun's eye beam. Its geometry rides the +/// shared effect-quad stream beside the ribbon trails. +mod chain_beam; +pub(crate) use chain_beam::ChainHops; +use chain_beam::{simulate_chain_beams, spawn_chain_beams}; // The container feed reads the icon column off the same catalog resource (one DBC parse). pub(crate) use attach::spawn_joints; pub(crate) use equipment::ItemDisplays; @@ -514,6 +521,33 @@ impl Plugin for EntitiesPlugin { .in_set(crate::billboard::BillboardPlace) .after(crate::particles::buffer::begin_effect_frame), ) + // The chain-beam spawner (0955): in the visuals set, so the cast router — which is + // `.before(EntityVisualsSet)` — has already emitted this frame's beam plays, and the + // net stage's Commands (the hop arrays) have already been applied. + .add_systems( + Update, + spawn_chain_beams + .in_set(EntityVisualsSet) + .after(WorldStage::Net), + ) + // …and its per-frame geometry, beside the ribbon trails and for the same reason: a + // beam's endpoints are attachment joints, so it must sample the pose the billboard + // palette and the rig finalizer just wrote. + // + // `.after(begin_effect_frame)` is the load-bearing one, and its omission is what made + // the whole beam invisible on first ship (B161): the clear carries an extra + // `.after(face_billboards)` the beam sim does not, so without this edge the sim + // becomes runnable a step EARLIER and its vertices are wiped before extract — every + // frame, silently, with the arithmetic perfect. Every writer into the shared stream + // declares this; the tripwire in `commit` now refuses a write that precedes the clear. + .add_systems( + PostUpdate, + simulate_chain_beams + .in_set(crate::billboard::BillboardPlace) + .after(crate::billboard::billboard_joint_palette) + .after(crate::creature_anim::finalize_rig_worlds) + .after(crate::particles::buffer::begin_effect_frame), + ) // Terrain conform (decisions 0482/0486, the byte law of wow-re `terrain-tilt.md`): // reads each flagged unit's Update-final transform, writes its conform node's // local rotation — before propagation so the composite's globals carry this diff --git a/crates/benilla/src/entities/chain_beam.rs b/crates/benilla/src/entities/chain_beam.rs new file mode 100644 index 00000000..767c2a2f --- /dev/null +++ b/crates/benilla/src/entities/chain_beam.rs @@ -0,0 +1,992 @@ +//! Spell **chain beams** — Chain Lightning's arcs, Drain Life's rope of soul, Mind Flay's mana +//! beam, Chain Heal's arc, C'Thun's eye beam (decision 0955, slice 2: the renderer). +//! +//! A kit whose `CharProc` decodes to a chain ([`benilla_formats::ChainProc`]) draws a **polyline of +//! hops** — `caster → t1 → t2 → t3` — with one ribbon per hop, each subdivided, jittered per frame +//! and scrolled. The data half (the DBC row, the sentinel fix, the wire) is slice 1; this module is +//! the client's `LightningObject` (`0x6ec460`) + `CLightning` (`0x7af9b0`/`0x7afcb0`) pair, whose +//! every load-bearing number is byte-VERIFIED in wow-re `system/spell/scratch/chain-beam-law.md` +//! (their `31715619`). +//! +//! ## What the reference does, and what this transcribes +//! +//! **The hop list is unit state, not packet state.** The reference parks it on the caster (the +//! growable array at `unit+0xd44`) — [`ChainHops`] here — filled by **two** producers, `SMSG_SPELL_GO`'s +//! hit list (`0x6e800d`) and `SMSG_SPELL_UPDATE_CHAIN_TARGETS` (`0x605767`), both dropping the +//! caster's own guid, and **consumed exactly once** by the next chain proc (`0x60db72` zeroes the +//! count on every exit path, including the ones that draw nothing). +//! +//! **Target selection** (`0x60dad4`–`0x60db19`): when the playing kit's spell **is** the unit's +//! `UNIT_CHANNEL_SPELL`, its `UNIT_FIELD_CHANNEL_OBJECT` is set, and the hop count is `<= 1`, the +//! beam runs to that single channel object; otherwise it runs the hop array, and an **empty array +//! draws nothing at all**. +//! +//! **Per hop** (`Bolt[i] = (i, i+1)`, `0x6ec980`): a window `[t0 + i × stagger, + life)` — so a +//! 3-hop Chain Lightning arcs *outward* — and the whole beam expires at `t0 + hops × life`. The +//! **flag** ([`ChainProc::flag`], the decoded `CharParamTwo`) is the entire cast/channel split: with +//! it set, the per-hop window and the expiry are both bypassed and the beam lives until swept +//! (`LightningObject::Stop 0x6ece10`, whose one caller image-wide is the channel teardown). +//! +//! **Endpoints are identities, re-resolved from the live units every frame** (`0x6ec460` runs both +//! through `ClntObjMgr` each tick), so a beam tracks a moving caster and a moving target; a hop +//! whose unit is not resolvable is *hidden*, not re-pathed. The caster's end anchors at its `$CSL` +//! marker, else `base + 0.75 × modelHeight × modelScale` (`0x6ec6f0`); every other endpoint anchors +//! at the M2 attachment named by the spell's own `SpellVisual` field 9 — the same ordinal the +//! missile homes to — with 34 as the fallback and the unit's base below that (`0x6ec780`). +//! +//! **Geometry per hop** (`0x7af6d0`), all VERIFIED: +//! - `n = trunc(len / avgSegLen + 2.0)` sub-segments, `n+1` points, `point[i] = lerp(a, b, i/n)`; +//! the `+2` is a floor, so even a one-yard hop bends. +//! - each interior point takes an independent 3-vector in `[−1, 1]³` scaled by `len × noiseScale`, +//! **re-rolled every frame**, and the live polyline advects toward it `0.75` old / `0.25` new. +//! - the ribbon's cross-section is the **world-horizontal** perpendicular `(−d.y, d.x, 0)` in WoW +//! axes — [`Vec3::Y.cross`] here, the same vector — normalized only when it is longer than +//! `0.001`, so a hop pointing straight up (or straight at a camera looking down it) collapses to +//! nothing. That is the reference's own regime, not an artefact. +//! - an interior vertex sits at `p ± 0.5·(perp₍ᵢ₋₁₎ + perpᵢ)·halfWidth`; **both ends collapse to a +//! point** with `v = 0.5` — the beam is a spindle, tapered at caster and target. +//! - `u` runs `0 → 1` caster→target, translated by `−(phase / period)` where +//! `phase = fmod(phase + dt, period)`; a **negative** period reverses the scroll, which is what +//! flows the four drain textures back toward the caster. +//! +//! **Render state** (`0x7afcb0`, decoded against wow-re's own verified `EGxRs` map): additive +//! `SRC_ALPHA/ONE`, emissive white — a beam is **never tinted** — two-sided, depth-write off, fog +//! **off**. It rides the shared effect-quad stream ([`crate::particles::buffer::EffectQuads`]) like +//! every other dynamic effect; `crate::ribbons` is the structural model (same strip-as-quads +//! conversion, same commit). +//! +//! **Named approximations.** (a) The strand count (`CharParamOne`, ≤ 3 — only Chain Burn ships > 1) +//! is modelled as N independently-jittered copies of one polyline: the reference builds them from +//! byte-identical arguments and they diverge *only* through their interleaved draws on the shared +//! PRNG, which is a shared-generator detail we do not reproduce, so ours diverge by construction +//! instead. (b) The `SMSG_SPELL_GO` producer leg is gated in the reference on `0x6e4870`'s return, a +//! predicate the §5 could not settle; we fill unconditionally — the superset, harmless because +//! consumption still requires a chain proc, and because every producer clears before it fills. +//! (c) A channel re-enters the dispatcher every tick (`0x612b18`) where we hold one beam for the +//! channel's life; the observable — a steady beam that ends with the channel — is the same. +//! (d) A beam takes no owner-last draw rung and no water-plane interleave: it belongs to no model. + +use benilla_formats::{ChainEffect, MISSILE_ATTACH_TABLE}; +use bevy::prelude::*; + +use crate::creature_anim::{ChainProcPlay, FxClass, SpellKitFx, SpellVisuals}; +use crate::net::GuidIndex; +use crate::particles::buffer::{EffectDrawSpec, EffectFog, EffectQuads, EffectVertex}; +use crate::player::WorldCamera; + +use super::{BoneAttach, OverheadFallback}; + +/// The caster-end anchor's height factor when the model has no `$CSL` marker: the reference's +/// `base + (0, 0, modelHeight × modelScale × 0.75)` (`0x6ec73e`–`0x6ec771`, the `0.75f` at +/// `0x8012cc`). Our stand-in for `obj+0x90` is the model's authored bbox z-extent — the same +/// "model height" the overhead-anchor fallback reads ([`OverheadFallback`]). +const CASTER_HEIGHT_FACTOR: f32 = 0.75; + +/// The attachment every non-caster endpoint falls back to when the spell's `SpellVisual` field 9 +/// names no usable one — the reference's literal `0x22` (`0x6ec7b5`–`0x6ec7c7`). Below it, the +/// unit's own position (which is what [`attach_world_pos`] already degrades to). +const CHAIN_ATTACH_FALLBACK: u16 = 34; + +/// The subdivision floor's constant (`fadd [0x801628] = 2.0f` @ `0x7af716`): `n = trunc(len/avg + 2)`. +const SUBDIVISION_FLOOR: f32 = 2.0; + +/// The normalisation guard on the cross-section vector (`fcom [0x801360]` @ `0x7b01bd`): a +/// perpendicular shorter than this is left **un-normalized**, collapsing that segment's width. +const PERP_EPSILON: f32 = 0.001; + +/// The per-frame advection weight on the live polyline (`0x7afc10`/`0x7afc38`): +/// `main = 0.75·main + 0.25·fresh`, interior points only. +const ADVECT_KEEP: f32 = 0.75; + +/// A backstop on one hop's sub-segment count — the reference has none (it trusts the table), but a +/// long hop against a small `avgSegLen` would otherwise size a vertex run off table data. The +/// shipped worst case is far below this: 30 yd at `avgSegLen` 2.78 is 12. +const MAX_SUBDIVISIONS: usize = 256; + +/// The caster's chain-target **hop array** — the reference's growable `unit+0xd44` +/// (`{capacity, count, data, quantum}`; the guid list, in wire order, with the caster's own guid +/// dropped as it fills, `0x6057bf`/`0x6057c9`). +/// +/// Two producers write it (`crate::net::apply::spells`: the `SMSG_SPELL_GO` hit list and +/// `SMSG_SPELL_UPDATE_CHAIN_TARGETS`), each **clearing before it fills**; [`spawn_chain_beams`] +/// consumes it **once** and removes it, exactly as `0x60db72` zeroes the count on every exit — +/// including the paths that draw nothing. Targets not streamed to us drop out as they resolve: an +/// endpoint we cannot place would be invisible anyway (the reference hides such a hop too). +#[derive(Component)] +pub(crate) struct ChainHops(pub(crate) Vec); + +/// One hop of a live beam — the reference's 16-byte `Bolt`, whose `idxA`/`idxB` are always +/// `(i, i+1)` into the node list. +struct Bolt { + /// Absolute scene seconds — `t0 + i × boltStagger` (`0x6ec9d7`–`0x6ec9e8`). Ignored while the + /// beam is persistent (`0x6ec520` bypasses the window on the flag). + start: f32, + /// `start + boltLife` (`0x6ec9eb`). Ignored while persistent. + end: f32, + /// The live jittered polyline, one per strand — the reference's per-`CLightning` point array, + /// advected rather than rebuilt so the jitter reads as motion instead of noise. Empty until the + /// hop's first drawn frame (and cleared whenever its subdivision count changes). + strands: Vec>, +} + +/// A live chain beam — the reference's 0x50-byte `LightningObject`. +#[derive(Component)] +pub(crate) struct ChainBeam { + /// The spell that drew it — the reap's key (`node+0x44`). + spell_id: u32, + /// The unit it hangs off. Its loss ends the beam: nothing else would ever reap a persistent one. + caster: Entity, + /// `[caster, hop0, hop1, …]` — **identities**, re-resolved every frame (`node+0x10`, the guid + /// array). The participant set never changes; a beam does not re-target. + nodes: Vec, + bolts: Vec, + effect: ChainEffect, + texture: Handle, + /// The M2 attachment every non-caster endpoint anchors at, resolved once from the spell's own + /// `SpellVisual` field 9 (the reference resolves it per frame; the row cannot change). + dest_tag: u16, + /// The decoded `CharParamTwo`: a **channel** beam, which never expires by time (`node+0x48`). + persistent: bool, + /// `t0 + hops × boltLife` (`0x6ecd30`). Ignored while [`Self::persistent`]. + expiry: f32, + /// The texture-scroll accumulator, seconds (`node`-side `+0x60`): `fmod(phase + dt, period)`. + phase: f32, + rng: u32, +} + +/// `Textures\SpellChainEffects\Lightning.blp` → its `mpq://` load URL (the lowercase/forward-slash +/// form every other BLP load in the client uses). +fn beam_texture_url(raw: &str) -> String { + format!("mpq://{}", raw.to_ascii_lowercase().replace('\\', "/")) +} + +/// One xorshift draw in `[−1, 1)` — our stand-in for the reference's `((rand & 0x7fffff) | +/// 0x3f800000)` mantissa trick re-centred through the same `2.0`. Same distribution, different +/// generator: the beam's look rests on the *amplitude* (`len × noiseScale`) and the 0.75/0.25 +/// advection, both exact, never on the reference's draw sequence. +fn jitter(rng: &mut u32) -> f32 { + *rng ^= *rng << 13; + *rng ^= *rng >> 17; + *rng ^= *rng << 5; + // The top 24 bits as [0,1), doubled and re-centred — the reference's own [1,2)→[−1,1) shape. + (*rng >> 8) as f32 / (1u32 << 24) as f32 * 2.0 - 1.0 +} + +/// The **caster's** end (`0x6ec6f0`): the `$CSL` marker at the live pose, else +/// `base + 0.75 × modelHeight × modelScale`. `None` only when the caster is gone. Every *other* +/// endpoint goes through the missile lane's attachment resolver instead — `0x6ec780` and +/// `0x61ceb0` read the same table the same way. +fn caster_world_pos( + caster: Entity, + units: &Query<(&GlobalTransform, Option<&BoneAttach>)>, + joints: &Query<&GlobalTransform>, + heights: &Query<&OverheadFallback>, +) -> Option { + let (base, bones) = units.get(caster).ok()?; + let marker = bones.and_then(|b| { + let &(bone, offset) = b.markers.get(b"$CSL")?; + Some(joints.get(b.anchor(bone)?).ok()?.transform_point(offset)) + }); + Some(marker.unwrap_or_else(|| { + let (scale, _, translation) = base.to_scale_rotation_translation(); + let height = heights.get(caster).map_or(0.0, |h| h.0); + translation + Vec3::Y * (scale.y * height * CASTER_HEIGHT_FACTOR) + })) +} + +/// The kit's chain proc → the beam's participants, the reference's selection exactly +/// (`0x60dad4`–`0x60db19`): the channel object when the kit's spell **is** the live channel, that +/// channel names an object, and the hop array holds at most one entry; else the hop array; else +/// nothing at all. +fn select_targets( + spell_id: u32, + hops: Option<&ChainHops>, + store: &crate::net::ObjectStore, + index: &GuidIndex, +) -> Vec { + let hops = hops.map(|h| h.0.as_slice()).unwrap_or_default(); + let channelling = spell_id != 0 && store.0.unit_channel_spell() == spell_id; + if channelling && hops.len() <= 1 { + if let Some(object) = store.0.unit_channel_object().filter(|g| *g != 0) { + // `count = 1, ptr = descriptor+0x38` — the single-target path. A channel object we + // haven't streamed leaves the beam with nothing to run to, like a hidden hop. + return index.0.get(&object).copied().into_iter().collect(); + } + } + hops.to_vec() +} + +/// Spawn/replace/reap the beam entities: the CharProc dispatcher's beam case, ECS-side. +/// +/// Reaps run **before** plays so a GO's own spell-id-keyed reap (which precedes its cast-kit play in +/// the router) never eats the beam that play is about to draw. Only **persistent** beams are +/// reapable: the reference only ever publishes a flagged node to the owner slots that the channel +/// teardown sweeps (`0x6ecdaa` gates the AddRef on the flag), so a one-shot beam always runs its own +/// clock to the end. +#[allow(clippy::too_many_arguments)] +pub(super) fn spawn_chain_beams( + mut commands: Commands, + time: Res