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