Chain Lightning draws its chain, and the Controls page lands

- the beam is on screen: Chain Lightning arcs outward hop by hop and Drain Life ropes from hand to victim — a write-order edge was erasing the finished geometry every frame, and a tripwire now guards the whole class
- the Controls page lands (sticky targeting, auto loot, invert mouse), UI scale waits for the era Apply button, and the terrain-distance slider retires
- with auto-loot in effect a lootable corpse shows the triple-pouch cursor, the real client's tell
- the character-screen freeze before a loading screen is gone: the shader warm-up hides behind a fully presented cover and tears down only its own roots

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-04 18:54:58 +07:00
co-authored by Claude Fable 5
parent 060aa574fb
commit a03cb5d5d2
32 changed files with 2686 additions and 329 deletions
-1
View File
@@ -1,5 +1,4 @@
<div align="center">
<img src="banner.jpg" alt="">
<h1>benilla</h1>
<p><b>A from-scratch World of Warcraft 1.12.1 client in Rust and <a href="https://bevy.org">Bevy</a></b></p>
<p>
+293
View File
@@ -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 -- <wmo-path-or-substring>`
//! 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 <wmo-path-or-substring>"))?;
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<u16> = 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<u16>, 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
}
@@ -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.
@@ -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,
);
}
@@ -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)",
@@ -631,7 +631,7 @@ pub fn wmo_group_submeshes(group_bytes: &[u8], root: &WmoRoot) -> Result<Vec<Ren
// cloth as the SAME faces twice with reversed winding (one copy per side — B38's tent
// awning, `wmo_doubled`), each meant to be culled from its wrong side. Drawing them
// two-sided rasterises both copies into an ulp-level depth tie, and the per-pixel
// winner is floating-point noise — the B38 flicker/latch (decision 0677).
// winner is floating-point noise — the B38 flicker/latch (decision 0680).
// WMO has no skeleton, so the per-vertex skin (`_globals`) is unused.
let (mut submesh, _globals) = remap_submesh(
global_indices.into_iter(),
@@ -73,25 +73,26 @@
//! | 2 | `Width` | **HALF**-width — the visible ribbon is `2 × field2` yards |
//! | 3 | `NoiseScale` | jitter amplitude as a **fraction of beam length** (`len × field3`), **re-rolled every frame** (`0x7b0950` dirties the geometry per frame), with a `0.75/0.25` advection blend against the previous roll |
//! | 4 | ~~`TexCoordScale`~~ **`ScrollPeriod`** | **not a scale at all** — the scroll **period in seconds**: `phase = fmod(phase + dt, field4)` (`0x7af9d7`), `u = -(phase / field4)` (`0x7b055f`). A **negative** value reverses the scroll direction, magnitude unchanged — which is why the four *drain* rows ship `-0.5`: the texture flows back toward the caster |
//! | 5 | `SegDuration` (ms) | the beam's life: expiry = `now + segmentCount × field5`, and **only for a one-shot** (see the flag below). The client also stores a seconds-scaled copy (`× 0.001f`, the constant at `0x801360`) which it then **never reads** |
//! | 6 | `SegDelay` (ms) | **never reaches the renderer** — read from the row, dead thereafter |
//! | 5 | ~~`SegDuration`~~ **`BoltLife`** (ms) | how long ONE HOP burns — `Bolt[i].end = Bolt[i].start + field5` (`0x6ec9eb`) — and the whole object's expiry, `now + boltCount × field5` (`0x6ecd30`). Per **hop**, not per subdivision segment. The client also stores a seconds-scaled copy (`× 0.001f`, the constant at `0x801360`) which it then **never reads** |
//! | 6 | ~~`SegDelay`~~ **`BoltStagger`** (ms) | the delay between HOPS lighting up — `Bolt[i].start = t0 + i × field6` (`0x6ec9da`), its one consumer. It never reaches the *renderer*, which is what makes it look dead from the geometry side; it is what makes a 3-hop Chain Lightning arc **outward** rather than appearing all at once |
//! | 7 | `Texture` | the beam's texture |
//!
//! The beam is **never tinted**: its colour word is always `0xFFFFFFFF`. Render state, settled
//! against wow-re's own verified `EGxRs` map: **additive `SRC_ALPHA/ONE`, two-sided, depth-write
//! OFF, fog off, emissive white, alpha-test `GEQUAL 1/255`**.
//!
//! **Geometry:** a chain is one polyline — `count+1` points, `count` segments — running **caster →
//! t1 → t2 → t3**, never a fan and never one beam per target (the client's own `Bolt` records name
//! `idxA = i`, `idxB = i+1`). The endpoints are **re-resolved from the live units every frame**
//! (`0x6ec460`), so a beam tracks a moving caster and a moving target. The caster's end anchors at
//! its `$CSL` attachment or, failing that, `0.75 × modelHeight × modelScale` (`0x6ec6f0`); every
//! other endpoint anchors at an M2 attachment resolved from the spell's own `SpellVisual` field 9
//! — the **same dest-attachment ordinal the missile uses** ([`MISSILE_ATTACH_TABLE`]) — with
//! attachment 34 as the fallback.
//! **Geometry:** a chain is one polyline of `count+1` **nodes** and `count` **hops** running
//! **caster → t1 → t2 → t3**, never a fan and never one beam per target (the client's own `Bolt`
//! records name `idxA = i`, `idxB = i+1`). Each hop is then subdivided on its own by field 1. The
//! endpoints are **re-resolved from the live units every frame** (`0x6ec460`), so a beam tracks a
//! moving caster and a moving target. The caster's end anchors at its `$CSL` attachment or,
//! failing that, `0.75 × modelHeight × modelScale` (`0x6ec6f0`); every other endpoint anchors at
//! an M2 attachment resolved from the spell's own `SpellVisual` field 9 — the **same
//! dest-attachment ordinal the missile uses** ([`MISSILE_ATTACH_TABLE`]) — with attachment 34 as
//! the fallback.
//!
//! **Lifetime** is the flag's, and only the flag's: a beam is dead when `flag == 0 && now >=
//! expiry`. So a **cast** beam (flag 0) self-terminates after `segmentCount × SegDuration`, and a
//! 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<HashMap<u32, ChainEffect>> {
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(),
},
);
@@ -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<u32> = 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.
+48 -13
View File
@@ -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]
+203 -45
View File
@@ -1,10 +1,12 @@
<Ui>
<!--
The OPTIONS window (decision 0950) — the shell (slice A) and the real PAGES: Audio
(0957), Graphics (0959). Opened from the game menu's Options button. Setting rows ride
the CVar store (0954): rows read GetCVar on show, write SetCVar on interaction, and
Defaults resets the visible page's CVars to their registered defaults. Pages without
rows are still empty and keep Defaults disabled.
(0957), Graphics (0959), Controls (0961). Opened from the game menu's Options button.
Setting rows ride the CVar store (0954): rows read GetCVar on show, write SetCVar on
interaction, and Defaults resets the visible page's CVars to their registered defaults.
Pages without rows are still empty and keep Defaults disabled. One row defers — uiScale
stages a pending value and commits through the era Apply button (0961), because a live
write would rescale the very slider being dragged.
This window is the one deliberate era-1.15.9 skin in a 1.12 shell (0950): the 1.12
OptionsFrame family (four separate panels) collapses into the era SettingsPanel look —
@@ -71,10 +73,15 @@
only — the engine slider's scrollbar-faithful posture); era rows with no 1.12 CVar +
benilla knob behind them (output device, Dialog volume, loop music, channels, cache;
the whole era Graphics dropdown zoo) don't ship. A slider shows the 1.12 panel's
range even where the benilla knob's clamp is wider (farclip's dev headroom to 1200,
uiScale's 0.5..1.5): a config value outside the page range displays clamped — thumb
and readout agree on the nearest expressible value, the engine keeps running the
config's truth, and nothing writes back until the player actually moves the row.
range even where the benilla knob's clamp is wider (uiScale's env clamp is 0.5..1.5):
a config value outside the page range displays clamped — thumb and readout agree on
the nearest expressible value, the engine keeps running the config's truth, and
nothing writes back until the player actually moves the row.
- **Apply-flow cuts (0961)**: the era's close-with-pending 3-button confirm dialog
(GAME_SETTINGS_CONFIRM_DISCARD) is not modeled — hiding the window silently discards
pending edits (the era's own hide path wipes without committing too); the 8-second
"keep these settings?" revert timer (CommitFlag.Revertable) is not modeled either —
Apply commits outright.
- Era rows this slice does not ship: the Accessibility group, and Languages/Network under
System — pages with no benilla knobs behind them yet (0950's "only rows backed by real
knobs" rule). Nameplates joined in slice B (real nameplate knobs exist to back its
@@ -203,11 +210,12 @@
-- benilla knob behind them (Enable Sound=Sound_EnableAllSound, Dialog, output device,
-- channels…) do not ship — the 0950 honest-tree rule applied to settings.
OPTIONS_PAGE_ROWS = {
Controls = { "RowStickyTarget", "RowAutoLoot", "RowInvertMouse" },
Audio = {
"RowEnableAll", "RowMaster", "RowSound", "RowMusic", "RowAmbience",
"RowEnableMusic", "RowEnableAmbience",
},
Graphics = { "RowUiScale", "RowFarclip" },
Graphics = { "RowUiScale" },
}
function OptionsRow_OnLoad(row, cvar, label, child)
@@ -256,13 +264,26 @@
function OptionsCheckbox_OnClick(box)
local row = box:GetParent()
-- The 1.12 panel's click sounds, quirk kept: the just-CHECKED box plays the
-- CheckBoxOff kit and vice versa (SoundOptionsFrame.lua's OnClick, verbatim).
if box:GetChecked() then
local checked = box:GetChecked()
-- Click kits: the 1.12 panels disagree with EACH OTHER — the interface panel's
-- PlayClickSound maps checked→CheckBoxOn (OptionsFrame.lua l.509), while the sound
-- panel's own OnClick maps checked→CheckBoxOff (SoundOptionsFrame.lua, the 0957
-- quirk) — so each page keeps its own panel's mapping (soundQuirk on Audio rows).
local kitOn = checked
if row.soundQuirk then kitOn = not kitOn end
if kitOn then
PlaySound("igMainMenuOptionCheckBoxOn")
else
PlaySound("igMainMenuOptionCheckBoxOff")
end
-- The write: checked = 1 — unless the row is value-INVERTED (Sticky Targeting:
-- 1.12 checks it when deselectOnClick is "0", era wraps the same CVar in
-- NegateBoolean; both references agree).
local on = checked
if row.invert then on = not on end
if on then
SetCVar(row.cvar, 1)
else
PlaySound("igMainMenuOptionCheckBoxOn")
SetCVar(row.cvar, 0)
end
OptionsPage_UpdateDependencies(OptionsFrame.selectedCategory)
@@ -295,7 +316,22 @@
-- compares equal to it as a STRING and stays out of the config file ("0.40"
-- would read as moved-off-"0.4").
local str = string.gsub(string.format("%.2f", value), "%.?0+$", "")
SetCVar(row.cvar, str)
if row.deferred then
-- era CommitFlag.Apply, transcribed (Blizzard_Setting.lua's staged
-- SetValue half): the control shows the new value, the ENGINE keeps the
-- old one, and the CVar moves only when Apply commits — dragging a
-- slider that rescales itself mid-drag is exactly what this flag
-- exists to prevent. Dragging back onto the committed value clears the
-- pending (era's IsModified: a pending EQUAL to current isn't one).
if str == GetCVar(row.cvar) then
row.pending = nil
else
row.pending = str
end
OptionsApply_Update()
else
SetCVar(row.cvar, str)
end
end
end
@@ -313,12 +349,22 @@
local box = getglobal(base .. rkey .. "Check")
if box then
-- SetChecked takes the CVar string exactly like the 1.12 panel: the numeric
-- read means "0" unchecks (button-check-and-state-texture.md).
box:SetChecked(GetCVar(row.cvar))
-- read means "0" unchecks (button-check-and-state-texture.md). A
-- value-inverted row checks on "0" instead — UIOptionsFrame.lua's own
-- GAMEFIELD_DESELECT arm (`checked = 1` when the CVar reads "0").
if row.invert then
local checked
if GetCVar(row.cvar) == "0" then checked = 1 end
box:SetChecked(checked)
else
box:SetChecked(GetCVar(row.cvar))
end
else
local slider = getglobal(base .. rkey .. "ControlSlider")
row.refreshing = 1
slider:SetValue(tonumber(GetCVar(row.cvar) or "0") or 0)
-- Pending first (era GetValue returns the pendingValue while one is
-- staged): coming back to a page with an un-applied edit shows the edit.
slider:SetValue(tonumber(row.pending or GetCVar(row.cvar) or "0") or 0)
row.refreshing = nil
-- SetValue fires only on a CHANGE — write the readout unconditionally,
-- from the slider's OWN value: in range that IS the CVar (SetValue stores
@@ -329,6 +375,7 @@
end
end
OptionsPage_UpdateDependencies(key)
OptionsApply_Update()
end
-- Per-page dependency rules. Audio's is the 1.12 rule, verbatim: Enable All Sound off
@@ -351,15 +398,70 @@
-- Defaults, live on any page with rows: every row's CVar back to its registered
-- default (era resets per-setting the same way; the era all-pages confirm dialog is
-- not modeled — Defaults resets the VISIBLE page).
-- not modeled — Defaults resets the VISIBLE page). A pending edit dies with it — the
-- default write supersedes what Apply would have committed.
function OptionsPage_Defaults(key)
local base = "OptionsFrameContainerBody" .. key
for _, rkey in ipairs(OPTIONS_PAGE_ROWS[key]) do
local row = getglobal(base .. rkey)
row.pending = nil
SetCVar(row.cvar, GetCVarDefault(row.cvar))
end
OptionsPage_Refresh(key)
end
-- ── The Apply flow (0961): era CommitFlag.Apply, cut to our rows ────────────────────
-- Pending edits are PANEL-wide like era's modified table: they survive a category
-- switch (the row redisplays its pending on return) and commit together; only hiding
-- the window discards them (era wipes on hide too — its close-time 3-button confirm
-- dialog and the 8-second revert timer are not modeled, SCOPE note).
-- The era Apply gate (HasUnappliedSettings + SetApplyButtonEnabled): the button is
-- enabled AND shown together — it EXISTS only while something is pending.
function OptionsApply_Update()
local pending
for key, rows in pairs(OPTIONS_PAGE_ROWS) do
local base = "OptionsFrameContainerBody" .. key
for _, rkey in ipairs(rows) do
if getglobal(base .. rkey).pending then pending = 1 end
end
end
if pending then
OptionsFrameApplyButton:Enable()
OptionsFrameApplyButton:Show()
else
OptionsFrameApplyButton:Disable()
OptionsFrameApplyButton:Hide()
end
end
-- Commit (era SettingsPanelMixin:CommitSettings, cut to our rows): every pending
-- value everywhere becomes a real SetCVar, and the button goes away.
function OptionsPage_Apply()
for key, rows in pairs(OPTIONS_PAGE_ROWS) do
local base = "OptionsFrameContainerBody" .. key
for _, rkey in ipairs(rows) do
local row = getglobal(base .. rkey)
if row.pending then
SetCVar(row.cvar, row.pending)
row.pending = nil
end
end
end
OptionsApply_Update()
end
-- Hide = discard (era's WipeModifiedTable-on-hide; the confirm dialog is cut): the
-- next open re-reads the committed CVars, so the sliders land back on the truth.
function OptionsPage_DiscardPending()
for key, rows in pairs(OPTIONS_PAGE_ROWS) do
local base = "OptionsFrameContainerBody" .. key
for _, rkey in ipairs(rows) do
getglobal(base .. rkey).pending = nil
end
end
OptionsApply_Update()
end
]]></Script>
<!-- The red panel button (era UIPanelButtonTemplate seat, 96x22): GameMenuButtonTemplate's
@@ -877,6 +979,48 @@
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Frames>
<!-- The Controls page (0961): the gameplay checkboxes with benilla
knobs behind them, in the era Controls page's own row order
(Controls.lua @1.15.9: Sticky Targeting #1, Auto Loot #6,
Invert Mouse #12 — its Mouse section header is cut while one
row survives under it). Sticky Targeting is value-INVERTED in
BOTH references (1.12 UIOptionsFrame checks it on
deselectOnClick=="0"; era wraps the CVar in NegateBoolean);
labels are 1.12 GlobalStrings where the row exists there
(GAMEFIELD_DESELECT_TEXT, INVERT_MOUSE), era's for Auto Loot
(no 1.12 row exists). -->
<Frame name="$parentControls" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Frames>
<Frame name="$parentRowStickyTarget" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "deselectOnClick", "Sticky Targeting")
self.invert = 1
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowAutoLoot" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowStickyTarget" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowStickyTarget" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "autoLootDefault", "Auto Loot")</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowInvertMouse" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowAutoLoot" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowAutoLoot" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "mouseInvertPitch", "Invert Mouse")</OnLoad></Scripts>
</Frame>
</Frames>
</Frame>
<!-- The Audio page. Row seats transcribe the era settings list
(Blizzard_SettingsList.lua l.43-47 + .xml: scroll box hung
-15/-20 off the header block with padLeft 25 / padRight 0 and
@@ -897,7 +1041,10 @@
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "MasterSoundEffects", "Enable All Sound")</OnLoad></Scripts>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "MasterSoundEffects", "Enable All Sound")
self.soundQuirk = 1
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowMaster" inherits="OptionsSliderRowTemplate">
<Anchors>
@@ -932,29 +1079,32 @@
<Anchor point="TOPLEFT" relativeTo="$parentRowAmbience" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowAmbience" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "EnableMusic", "Enable Music")</OnLoad></Scripts>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "EnableMusic", "Enable Music")
self.soundQuirk = 1
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowEnableAmbience" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowEnableMusic" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowEnableMusic" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>OptionsRow_OnLoad(self, "EnableAmbience", "Enable Ambience")</OnLoad></Scripts>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "EnableAmbience", "Enable Ambience")
self.soundQuirk = 1
</OnLoad></Scripts>
</Frame>
</Frames>
</Frame>
<!-- The Graphics page (0959): the two 1.12 Video Options sliders
with benilla knobs behind them, on the same era row seats.
Ranges/steps/labels are the 1.12 OptionsFrameSliders table
verbatim (OptionsFrame.lua l.24-26): uiscale 0.64..1.0 step
0.01 (UI_SCALE "UI Scale", percent readout — era's own
FormatPercentageRounded on its uiscale row), farclip 177..777
step 60 = (max-min)/10 (FARCLIP "Terrain Distance", raw-yards
readout — era's law when a slider has no formatter). 1.12's
useUiScale gate has no benilla knob (our scale always applies;
off ≡ 1.0) so the checkbox doesn't ship; era's Graphics page
never registers View Distance at all — Terrain Distance is
the 1.12 surface, kept. -->
<!-- The Graphics page (0959; farclip row retired 0961 — the
director's call: the page holds UI Scale alone for now, the
farclip CVar/knob/env/debug-panel plumbing all stay). Range/
step/label are the 1.12 OptionsFrameSliders table verbatim
(OptionsFrame.lua l.25): uiscale 0.64..1.0 step 0.01 (UI_SCALE
"UI Scale", percent readout — era's own
FormatPercentageRounded on its uiscale row). 1.12's useUiScale
gate has no benilla knob (our scale always applies; off ≡ 1.0)
so the checkbox doesn't ship. -->
<Frame name="$parentGraphics" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
@@ -969,16 +1119,11 @@
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "uiScale", "UI Scale")
OptionsSliderRow_Setup(self, 0.64, 1.0, 0.01)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowFarclip" inherits="OptionsSliderRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowUiScale" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowUiScale" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
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
</OnLoad></Scripts>
</Frame>
</Frames>
@@ -1094,8 +1239,7 @@
</OnClick>
</Scripts>
</Button>
<!-- The red Close (era SettingsPanel.CloseButton; Apply is deliberately absent — we
apply live, there is never a pending change to hold). -->
<!-- The red Close (era SettingsPanel.CloseButton). -->
<Button name="$parentCloseButton" inherits="OptionsRedButtonTemplate" text="Close">
<Anchors><Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-16" y="16"/></Offset></Anchor></Anchors>
<Scripts>
@@ -1105,6 +1249,17 @@
</OnClick>
</Scripts>
</Button>
<!-- The era Apply (SettingsPanel.ApplyButton: 96x22, RIGHT of Close's LEFT at -2,
Blizzard_SettingsPanel.xml l.52-57): exists only while a deferred row holds a
pending value — era enables and SHOWS it together (SetApplyButtonEnabled) —
and commits every pending edit on click. -->
<Button name="$parentApplyButton" inherits="OptionsRedButtonTemplate" text="Apply" hidden="true">
<Anchors><Anchor point="RIGHT" relativeTo="$parentCloseButton" relativePoint="LEFT"><Offset><AbsDimension x="-2" y="0"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:Disable()</OnLoad>
<OnClick>OptionsPage_Apply()</OnClick>
</Scripts>
</Button>
</Frames>
<Scripts>
<!-- Controls is the default page; a re-opened window keeps its last selection (the
@@ -1114,6 +1269,9 @@
OptionsFrame_UpdateScale()
OptionsFrame_SelectCategory(this.selectedCategory or "Controls")
</OnShow>
<!-- Hiding discards pending edits (the era wipes its modified table on hide; its
close-time confirm dialog is cut — SCOPE note). -->
<OnHide>OptionsPage_DiscardPending()</OnHide>
</Scripts>
</Frame>
</Ui>
+4 -2
View File
@@ -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::<SpellKitSound>()
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
// The kit's beam edge (0955) — `crate::entities` owns what it becomes.
.add_message::<ChainProcPlay>()
.add_message::<KitPush>()
// 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.
@@ -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<SpellKitSound>,
mut fx: MessageWriter<SpellKitFx>,
mut sheaths: MessageWriter<super::SheathRequest>,
// 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<MissileSpawn>,
MessageWriter<crate::entities::dest_fx::GroundBurst>,
MessageWriter<ChainProcPlay>,
),
visuals: Option<Res<SpellVisuals>>,
spells: Option<Res<crate::ui_action::Spells>>,
@@ -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).
@@ -43,6 +43,7 @@ fn app() -> App {
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
.add_message::<SheathRequest>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
HashMap::from([
@@ -202,6 +203,7 @@ fn precast_kit_sound_rings_once_at_start() {
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
.add_message::<SheathRequest>();
app.insert_resource(SpellVisuals(SpellVisualCatalog::from_tables(
HashMap::from([(
@@ -575,6 +577,7 @@ fn missile_spawn_defers_iff_the_cast_kit_animates() {
.add_message::<SpellKitFx>()
.add_message::<MissileSpawn>()
.add_message::<crate::entities::dest_fx::GroundBurst>()
.add_message::<super::ChainProcPlay>()
.add_message::<SheathRequest>();
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::<Messages<super::ChainProcPlay>>()
.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"
);
}
+3
View File
@@ -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",
+108 -62
View File
@@ -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::<f32>() 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<SoundConfig>,
mut scale: ResMut<UiScaleCvar>,
mut view: ResMut<ViewDistance>,
mut look: ResMut<LookConfig>,
mut click: ResMut<ClickConfig>,
mut loot: ResMut<LootConfig>,
) {
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<NonSendMut<UiScript>>,
mut persist: ResMut<CvarPersist>,
mut sound: ResMut<SoundConfig>,
mut scale: ResMut<UiScaleCvar>,
mut view: ResMut<ViewDistance>,
mut look: ResMut<LookConfig>,
mut click: ResMut<ClickConfig>,
mut loot: ResMut<LootConfig>,
) {
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::<LookConfig>()
.init_resource::<ClickConfig>()
.init_resource::<LootConfig>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
+2 -19
View File
@@ -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<crate::doodad_anim::UvAnimMaterials>,
mut particles: ResMut<ParticleTuning>,
mut sound_cfg: ResMut<crate::sound::SoundConfig>,
mut cull_probe: ResMut<crate::wmo_portal::WmoCullProbe>,
net_status: Res<crate::net::NetStatus>,
@@ -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| {
+34
View File
@@ -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
+992
View File
@@ -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<Entity>);
/// 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<Vec<Vec3>>,
}
/// 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<Entity>,
bolts: Vec<Bolt>,
effect: ChainEffect,
texture: Handle<Image>,
/// 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<Vec3> {
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<Entity> {
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<Time>,
mut plays: MessageReader<ChainProcPlay>,
mut kit_fx: MessageReader<SpellKitFx>,
visuals: Option<Res<SpellVisuals>>,
spells: Option<Res<crate::ui_action::Spells>>,
asset_server: Res<AssetServer>,
index: Res<GuidIndex>,
stores: Query<(&crate::net::ObjectStore, Option<&ChainHops>)>,
beams: Query<(Entity, &ChainBeam)>,
// The removal below is deferred to the next sync point, so a SECOND play on the same caster
// this frame (a cast kit and an impact kit, an auto-repeat's twin GO) would still see the
// array through the query. The reference zeroes the count in-place, mid-dispatch — this
// overlay is that, and it is the same shape as the router's own `pending` hold overlay.
mut consumed: Local<bevy::ecs::entity::EntityHashSet>,
) {
consumed.clear();
for ev in kit_fx.read() {
let SpellKitFx::Reap {
entity,
spell_id,
class: FxClass::Hold,
} = ev
else {
continue;
};
for (e, beam) in &beams {
if beam.persistent && beam.caster == *entity && beam.spell_id == *spell_id {
commands.entity(e).despawn();
}
}
}
let now = time.elapsed_secs();
for play in plays.read() {
// The caster may have died inside the same wire drain that produced this play (the router's
// own law) — and a beam is entirely *about* that unit.
let Ok((store, hops)) = stores.get(play.entity) else {
continue;
};
// Consume once, on every path — `0x60db72` zeroes the count even when nothing is drawn.
let hops = hops.filter(|_| !consumed.contains(&play.entity));
consumed.insert(play.entity);
commands.entity(play.entity).try_remove::<ChainHops>();
// `0x6ecbd0`'s own guards: no spell, no strands, no targets ⇒ silent return.
let targets = select_targets(play.spell_id, hops, store, &index);
if play.spell_id == 0 || play.proc.beams == 0 || targets.is_empty() {
continue;
}
let (Some(visuals), Some(spells)) = (visuals.as_deref(), spells.as_deref()) else {
continue;
};
let Some(effect) = visuals.0.chain_effect(play.proc.effect_id).cloned() else {
continue; // an id naming no row is the client's own no-op (`0x6ecc2e`)
};
// `0x6ec780`: the spell's OWN visual row, field 9's ordinal through the missile table —
// no ranged fallback (that belongs to the kit resolve, not to this one).
let dest_tag = spells
.catalog
.get(play.spell_id)
.and_then(|d| visuals.0.stages(d.visual))
.and_then(|s| MISSILE_ATTACH_TABLE.get(s.missile_attach as usize).copied())
.unwrap_or(CHAIN_ATTACH_FALLBACK);
// A replacing persistent beam takes the previous one's place (a channel re-armed on the
// same caster+spell), matching the effect-model lane's persistent-Begin law.
if play.proc.flag {
for (e, beam) in &beams {
if beam.persistent && beam.caster == play.entity && beam.spell_id == play.spell_id {
commands.entity(e).despawn();
}
}
}
let stagger = f32::from(u16::try_from(effect.bolt_stagger_ms).unwrap_or(u16::MAX)) / 1000.0;
let life = effect.bolt_life_ms as f32 / 1000.0;
let bolts: Vec<Bolt> = (0..targets.len())
.map(|i| {
let start = now + i as f32 * stagger;
Bolt {
start,
end: start + life,
strands: vec![Vec::new(); play.proc.beams as usize],
}
})
.collect();
let texture = asset_server.load::<Image>(beam_texture_url(&effect.texture));
let nodes = std::iter::once(play.entity).chain(targets).collect();
commands.spawn((
Transform::IDENTITY,
ChainBeam {
spell_id: play.spell_id,
caster: play.entity,
nodes,
expiry: now + bolts.len() as f32 * life,
bolts,
effect,
texture,
dest_tag,
persistent: play.proc.flag,
phase: 0.0,
// Per-beam seed: one strand per beam would otherwise draw the same jitter as
// its neighbour on the same frame (see the module's approximation (a)).
rng: 0x9E37_79B9 ^ (play.spell_id.wrapping_mul(2654435761)),
},
));
}
}
/// Advance the texture-scroll accumulator and derive the frame's `u` translate — the reference's
/// two halves (`0x7af9c7` Update, `0x7b0544` Render): `phase = fmod(phase + dt, period)` and
/// `u = −(phase / period)`, with a zero period disabling the scroll outright.
///
/// The **sign** rides through untouched: C `fmod` takes its dividend's sign, so a negative period
/// still lands the accumulator in `[0, |period|)` and only `u`'s direction flips — which is exactly
/// why the four drain rows' `−0.5` flows their texture back toward the caster.
fn advance_scroll(phase: f32, dt: f32, period: f32) -> (f32, f32) {
if period == 0.0 {
return (0.0, 0.0);
}
let phase = (phase + dt) % period;
(phase, -(phase / period))
}
/// One hop's fresh subdivision, jittered — the reference's `0x7af6d0` rebuild, run every frame.
/// Endpoints are exact; interior points take `len × noiseScale` of independent 3-vector jitter.
fn subdivide(a: Vec3, b: Vec3, effect: &ChainEffect, rng: &mut u32, out: &mut Vec<Vec3>) {
let len = a.distance(b);
let n = if effect.avg_seg_len > 0.0 {
(len / effect.avg_seg_len + SUBDIVISION_FLOOR).trunc() as usize
} else {
SUBDIVISION_FLOOR as usize
}
.clamp(SUBDIVISION_FLOOR as usize, MAX_SUBDIVISIONS);
let amp = len * effect.noise_scale;
out.clear();
out.reserve(n + 1);
for i in 0..=n {
let p = a.lerp(b, i as f32 / n as f32);
out.push(if i == 0 || i == n {
p
} else {
p + Vec3::new(jitter(rng), jitter(rng), jitter(rng)) * amp
});
}
}
/// One strand's polyline, written into the shared stream as a triangle strip expressed in quads
/// (the ribbon lane's conversion: strip triangles `(t₀,b₀,t₁),(b₀,b₁,t₁)` = quad `[b₀,b₁,t₁,t₀]`).
///
/// Cross-section, ends and texcoords are the reference's (`0x7b0196`–`0x7b0541`): the world-
/// horizontal perpendicular, un-normalized below [`PERP_EPSILON`]; an interior vertex at
/// `p ± 0.5·(perp₍ᵢ₋₁₎ + perpᵢ)·halfWidth`; both ends collapsed to a point at `v = 0.5`; `u`
/// running 0→1 caster→target plus the scroll translate. The colour is the beam's constant
/// `0xFFFFFFFF` — emissive white, never tinted.
fn push_strand(verts: &mut Vec<EffectVertex>, pts: &[Vec3], half_width: f32, u_scroll: f32) {
let count = pts.len();
if count < 2 {
return;
}
let seg_perp = |i: usize| -> Vec3 {
// `(−d.y, d.x, 0)` in WoW axes IS `Y × d` in ours — same vector, same magnitude, so the
// reference's 0.001 guard on the raw length transfers unchanged.
let raw = Vec3::Y.cross(pts[i + 1] - pts[i]);
if raw.length() > PERP_EPSILON {
raw.normalize()
} else {
raw
}
};
// (top, bottom, v) for point `i` — the collapsed ends share a vertex and stamp v = 0.5.
let rail = |i: usize| -> (Vec3, Vec3, f32) {
if i == 0 || i == count - 1 {
(pts[i], pts[i], 0.5)
} else {
let off = (seg_perp(i - 1) + seg_perp(i)) * 0.5 * half_width;
(pts[i] + off, pts[i] - off, 0.0)
}
};
let white = [1.0, 1.0, 1.0, 1.0];
let span = (count - 1) as f32;
let mut a = rail(0);
for i in 0..count - 1 {
let b = rail(i + 1);
let (ua, ub) = (i as f32 / span + u_scroll, (i + 1) as f32 / span + u_scroll);
// v: 0 on the top rail, 1 on the bottom — except at a collapsed end, where both are 0.5.
let (va_t, va_b) = (a.2.min(0.5), if a.2 == 0.5 { 0.5 } else { 1.0 });
let (vb_t, vb_b) = (b.2.min(0.5), if b.2 == 0.5 { 0.5 } else { 1.0 });
for (pos, uv) in [
(a.1, [ua, va_b]),
(b.1, [ub, vb_b]),
(b.0, [ub, vb_t]),
(a.0, [ua, va_t]),
] {
verts.push(EffectVertex {
pos: pos.to_array(),
uv,
color: white,
});
}
a = b;
}
}
/// Per frame: age the beam, re-resolve every hop's live endpoints, re-jitter and advect its
/// polyline, and write the ribbons into the shared effect-quad stream — the reference's
/// `LightningObject::Update` (`0x6ec460`) and `CLightning::Render` (`0x7afcb0`) in one pass.
#[allow(clippy::too_many_arguments)]
pub(crate) fn simulate_chain_beams(
time: Res<Time>,
mut commands: Commands,
mut quads: ResMut<EffectQuads>,
images: Res<Assets<Image>>,
world_cam: Query<Entity, With<WorldCamera>>,
units: Query<(&GlobalTransform, Option<&BoneAttach>)>,
joints: Query<&GlobalTransform>,
heights: Query<&OverheadFallback>,
mut beams: Query<(Entity, &mut ChainBeam)>,
mut scratch: Local<Vec<Vec3>>,
) {
let Ok(cam) = world_cam.single() else {
return;
};
let dt = time.delta_secs().min(0.1);
let now = time.elapsed_secs();
for (entity, mut beam) in &mut beams {
// Dead ⇔ `flag == 0 && now >= expiry` (`0x6ec6b9`); and a beam whose caster is gone has
// nothing left to hang off — the one reap a persistent beam would otherwise never get.
if (!beam.persistent && now >= beam.expiry) || !units.contains(beam.caster) {
commands.entity(entity).despawn();
continue;
}
let (phase, u_scroll) = advance_scroll(beam.phase, dt, beam.effect.scroll_period_s);
beam.phase = phase;
if !images.contains(&beam.texture) {
continue; // not resident yet — nothing to draw, nothing to advect against
}
let (dest_tag, half_width, persistent, texture) = (
beam.dest_tag,
beam.effect.half_width,
beam.persistent,
beam.texture.id(),
);
let ChainBeam {
nodes,
bolts,
effect,
rng,
..
} = &mut *beam;
let start = quads.begin();
let (mut anchor_sum, mut anchor_n) = (Vec3::ZERO, 0.0f32);
for (i, bolt) in bolts.iter_mut().enumerate() {
// The per-hop window — bypassed entirely while the flag is set (`0x6ec520`).
if !persistent && !(bolt.start <= now && now < bolt.end) {
bolt.strands.iter_mut().for_each(Vec::clear);
continue;
}
// Both endpoints re-resolved from the live units, every frame. Either missing hides
// this hop (`SetVisible(handle, 0)`) without touching the rest of the chain.
let dest = [dest_tag, CHAIN_ATTACH_FALLBACK];
let from = if i == 0 {
caster_world_pos(nodes[0], &units, &joints, &heights)
} else {
super::missile::attach_world_pos(nodes[i], dest, &units, &joints)
};
let (Some(from), Some(to)) = (
from,
super::missile::attach_world_pos(nodes[i + 1], dest, &units, &joints),
) else {
bolt.strands.iter_mut().for_each(Vec::clear);
continue;
};
anchor_sum += (from + to) * 0.5;
anchor_n += 1.0;
for pts in bolt.strands.iter_mut() {
subdivide(from, to, effect, rng, &mut scratch);
if pts.len() == scratch.len() {
// Advect the live polyline toward the fresh roll — interior only, endpoints
// exact (`0x7afbe7`–`0x7afc81`). This is what turns a per-frame re-roll into
// a crawl rather than a strobe.
let last = pts.len() - 1;
pts[0] = scratch[0];
pts[last] = scratch[last];
for k in 1..last {
pts[k] = pts[k] * ADVECT_KEEP + scratch[k] * (1.0 - ADVECT_KEEP);
}
} else {
// First frame, or the subdivision count changed under a moving endpoint:
// take the fresh polyline whole (the reference's dirty-bit rebuild).
pts.clear();
pts.extend_from_slice(&scratch);
}
push_strand(&mut quads.verts, pts, half_width, u_scroll);
}
}
if anchor_n == 0.0 {
continue; // every hop hidden — `commit_quads` over an empty range is a no-op anyway
}
quads.commit_quads(
start,
EffectDrawSpec {
cam,
texture,
// `EGxRs 0x07 = 3` → `glBlendFunc(SRC_ALPHA, ONE)`, and `0x0f = 0` → GL_FOG off.
// Depth-write off and two-sided come with the lane's additive pipeline.
blend: crate::particles::buffer::EffectBlend::Add,
fog: EffectFog::Off,
anchor: anchor_sum / anchor_n,
// No owner rung: a beam is not a model's emitter (module docs, approximation (d)).
bias: 0.0,
raster_bias: 0,
main_entity: entity,
light: None,
},
);
}
}
/// The beam's arithmetic, against the numbers wow-re read out of the binary — the subdivision floor,
/// the spindle taper, the world-horizontal cross-section and its degenerate regime, and the signed
/// scroll. Each of these is a value that would go straight into pixels, and two of the four columns
/// they read were mis-named in the community schemas until the §5 (decision 0955).
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use benilla_formats::{
char_proc_type, ChainProc, SpellCatalog, SpellDisplay, SpellVisualCatalog, VisualStages,
};
use super::*;
use crate::net::ObjectStore;
/// Chain Lightning's real chain (5875 `spellvis 421`): visual 36, `SpellChainEffects` id 1.
const CHAIN_SPELL: u32 = 421;
const CHAIN_VISUAL: u32 = 36;
/// `SpellChainEffects` id 1 — Chain Lightning's row, verbatim from the shipped table
/// (`benilla-extract <Data> chaincensus`).
fn lightning() -> ChainEffect {
ChainEffect {
avg_seg_len: 2.78,
half_width: 0.5,
noise_scale: 0.04,
scroll_period_s: 1.0,
bolt_life_ms: 1000,
bolt_stagger_ms: 300,
texture: "Textures\\SpellChainEffects\\Lightning.blp".into(),
}
}
/// A minimal app running only [`spawn_chain_beams`] over Chain Lightning's synthetic chain.
fn beam_app() -> App {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()));
app.init_asset::<Image>();
app.init_resource::<GuidIndex>();
app.add_message::<ChainProcPlay>()
.add_message::<SpellKitFx>();
app.insert_resource(SpellVisuals(
SpellVisualCatalog::from_tables(
HashMap::from([(
CHAIN_VISUAL,
VisualStages {
// Field 9's ordinal — index 1 of the missile table is attachment 34,
// which is also the chain's own fallback.
missile_attach: 1,
..Default::default()
},
)]),
HashMap::new(),
)
.with_chain_effect(1, lightning()),
));
app.insert_resource(crate::ui_action::Spells {
catalog: SpellCatalog::from_displays(HashMap::from([(
CHAIN_SPELL,
SpellDisplay {
visual: CHAIN_VISUAL,
..Default::default()
},
)])),
..crate::ui_action::Spells::empty_for_tests()
});
app.add_systems(Update, spawn_chain_beams);
app
}
/// A streamed unit: an empty descriptor store is all the spawner reads off a target.
fn unit(app: &mut App) -> Entity {
app.world_mut()
.spawn(ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[])))
.id()
}
/// A cast-stage Chain Lightning play: chain id 1, one strand, flag clear. The params go through
/// the client's small-int decode, which round-trips an integer written as a float.
fn play(entity: Entity, spell_id: u32) -> ChainProcPlay {
ChainProcPlay {
entity,
spell_id,
proc: ChainProc {
effect_id: 1,
beams: 1,
flag: false,
ty: char_proc_type::CHAIN_CAST,
},
}
}
/// Every live beam as `(nodes, [(hop start, hop end)], expiry, persistent)`.
#[allow(clippy::type_complexity)]
fn beams(app: &mut App) -> Vec<(Vec<Entity>, Vec<(f32, f32)>, f32, bool)> {
app.world_mut()
.query::<&ChainBeam>()
.iter(app.world())
.map(|b| {
(
b.nodes.clone(),
b.bolts.iter().map(|x| (x.start, x.end)).collect(),
b.expiry,
b.persistent,
)
})
.collect()
}
/// `n = trunc(len / avgSegLen + 2.0)`, `n+1` points — a **floor of two**, so even a hop shorter
/// than one segment still bends (`0x7af713`, the `2.0f` at `0x801628`). The 30-yard case is the
/// §5's own worked example: 30 / 2.78 + 2 → 12 segments, 13 points.
#[test]
fn subdivision_carries_the_plus_two_floor() {
let (e, mut rng, mut out) = (lightning(), 1u32, Vec::new());
for (len, want_points) in [(0.0, 3), (1.0, 3), (30.0, 13), (2.78, 4)] {
subdivide(Vec3::ZERO, Vec3::X * len, &e, &mut rng, &mut out);
assert_eq!(out.len(), want_points, "{len} yd");
}
// A row whose segment length is 0 (id 15, which no kit reaches) must not divide by zero.
let degenerate = ChainEffect {
avg_seg_len: 0.0,
..lightning()
};
subdivide(Vec3::ZERO, Vec3::X * 30.0, &degenerate, &mut rng, &mut out);
assert_eq!(out.len(), 3);
}
/// Endpoints are **exact** — the jitter is interior-only — and the interior offset is bounded by
/// `len × noiseScale` per axis (`amp = len × field3`, `0x7af748`).
#[test]
fn jitter_is_interior_only_and_scales_with_hop_length() {
let (e, mut rng, mut out) = (lightning(), 0x1234_5678u32, Vec::new());
let (a, b) = (Vec3::new(1.0, 2.0, 3.0), Vec3::new(31.0, 2.0, 3.0));
subdivide(a, b, &e, &mut rng, &mut out);
assert_eq!(
(out[0], *out.last().unwrap()),
(a, b),
"endpoints are exact"
);
let amp = 30.0 * e.noise_scale;
let mut moved = 0;
for (i, p) in out.iter().enumerate().take(out.len() - 1).skip(1) {
let straight = a.lerp(b, i as f32 / (out.len() - 1) as f32);
let d = *p - straight;
assert!(
d.x.abs() <= amp && d.y.abs() <= amp && d.z.abs() <= amp,
"point {i} strayed {d} beyond ±{amp}"
);
moved += usize::from(d.length() > 1e-6);
}
assert_eq!(moved, out.len() - 2, "every interior point takes a draw");
}
/// The ribbon spans `2 × halfWidth` on its **world-horizontal** cross-section, and **both ends
/// collapse to a point** — the beam is a spindle, not a slab (`0x7b04b9`–`0x7b0541`).
#[test]
fn the_ribbon_is_a_spindle_two_half_widths_across() {
let pts = [
Vec3::ZERO,
Vec3::new(0.0, 0.0, -5.0),
Vec3::new(0.0, 0.0, -10.0),
];
let mut verts = Vec::new();
push_strand(&mut verts, &pts, 0.5, 0.0);
// 2 segments × one quad each.
assert_eq!(verts.len(), 8);
// Quad corner order is [b₀, b₁, t₁, t₀]: the first quad's b₀/t₀ are the collapsed caster
// end, and the second's b₁/t₁ the collapsed target end.
assert_eq!(verts[0].pos, pts[0].to_array());
assert_eq!(verts[3].pos, pts[0].to_array());
assert_eq!(verts[5].pos, pts[2].to_array());
assert_eq!(verts[6].pos, pts[2].to_array());
// The middle point carries the full width, across the horizontal — the hop runs along −Z,
// so its cross-section is ±X.
let (mid_bottom, mid_top) = (Vec3::from(verts[1].pos), Vec3::from(verts[2].pos));
assert!(
(mid_top.distance(mid_bottom) - 1.0).abs() < 1e-5,
"2 × 0.5 yd"
);
assert!(
mid_top.y.abs() < 1e-6 && mid_bottom.y.abs() < 1e-6,
"horizontal"
);
// …and the collapsed ends stamp v = 0.5 on both rails.
assert_eq!(verts[0].uv[1], 0.5);
assert_eq!(verts[3].uv[1], 0.5);
}
/// A hop pointing straight up has **no** horizontal perpendicular: the reference skips the
/// normalisation below `0.001` (`0x7b01bd`) and the segment collapses toward zero width. That
/// regime — a beam vanishing when seen end-on — is the reference's, not a bug of ours.
#[test]
fn a_vertical_hop_collapses_instead_of_exploding() {
let pts = [Vec3::ZERO, Vec3::Y * 5.0, Vec3::Y * 10.0];
let mut verts = Vec::new();
push_strand(&mut verts, &pts, 0.5, 0.0);
let (mid_bottom, mid_top) = (Vec3::from(verts[1].pos), Vec3::from(verts[2].pos));
assert!(
mid_top.distance(mid_bottom) < 1e-6,
"a vertical hop has no width, and certainly no NaN"
);
assert!(verts.iter().all(|v| v.pos.iter().all(|c| c.is_finite())));
}
/// `u` runs 0 → 1 caster→target (`0x7afb20`), translated by the scroll (`0x7b057e`).
#[test]
fn u_runs_caster_to_target_and_the_scroll_translates_it() {
let pts = [Vec3::ZERO, Vec3::X * 5.0, Vec3::X * 10.0];
let mut verts = Vec::new();
push_strand(&mut verts, &pts, 0.5, 0.0);
assert_eq!(verts[0].uv[0], 0.0, "caster end");
assert_eq!(verts[5].uv[0], 1.0, "target end");
verts.clear();
push_strand(&mut verts, &pts, 0.5, -0.25);
assert_eq!(
verts[0].uv[0], -0.25,
"the whole run slides with the scroll"
);
assert_eq!(verts[5].uv[0], 0.75);
}
/// The scroll accumulator wraps on `|period|` and a **negative** period reverses `u`'s
/// direction with its magnitude unchanged — the four drains' `−0.5` is why their texture flows
/// back toward the caster (`0x7af9d7` / `0x7b055f`).
#[test]
fn the_scroll_period_sign_reverses_the_direction() {
// One second of a 1 s period sweeps u from 0 to −1 and wraps.
let (mut phase, mut u) = (0.0, 0.0);
for _ in 0..4 {
(phase, u) = advance_scroll(phase, 0.25, 1.0);
}
assert!(phase.abs() < 1e-6, "wrapped back to 0 after a full period");
assert!(u.abs() < 1e-6);
let (_, quarter) = advance_scroll(0.0, 0.25, 1.0);
assert!(
(quarter + 0.25).abs() < 1e-6,
"positive period ⇒ u goes negative"
);
// The drains: a −0.5 s period keeps the accumulator in [0, 0.5) and flips u's sign, so it
// sweeps 0 → +1 at 2 tiles/s where a +0.5 would sweep 0 → −1.
let (phase, u) = advance_scroll(0.0, 0.25, -0.5);
assert!((phase - 0.25).abs() < 1e-6, "accumulator stays positive");
assert!((u - 0.5).abs() < 1e-6, "…and u runs the other way");
// A zero period disables the scroll outright rather than dividing by it.
assert_eq!(advance_scroll(0.4, 0.25, 0.0), (0.0, 0.0));
}
/// The jitter draw stays in `[−1, 1)` — the amplitude bound the test above rests on.
#[test]
fn jitter_draws_stay_in_range() {
let mut rng = 0x9E37_79B9u32;
for _ in 0..10_000 {
let v = jitter(&mut rng);
assert!((-1.0..1.0).contains(&v), "{v} out of [−1, 1)");
}
}
/// A hop array holding at most one entry, on a unit channelling THIS spell at an object,
/// selects that **channel object** — the reference's single-target path (`0x60dae5`). Anything
/// else runs the array, and an empty array draws nothing at all.
#[test]
fn target_selection_prefers_the_channel_object_only_on_its_own_narrow_path() {
use benilla_protocol::ObjectFields;
const CHANNEL_SPELL: u32 = 689; // Drain Life
const OBJECT_GUID: u64 = 0xABC;
let victim = Entity::from_raw_u32(7).expect("a test entity id");
let hop = Entity::from_raw_u32(8).expect("a test entity id");
let mut index = GuidIndex::default();
index.0.insert(OBJECT_GUID, victim);
// Field 20 = UNIT_FIELD_CHANNEL_OBJECT (a guid pair), 144 = UNIT_CHANNEL_SPELL.
let channelling = ObjectStore(ObjectFields::from_pairs(&[
(20, OBJECT_GUID as u32),
(21, (OBJECT_GUID >> 32) as u32),
(144, CHANNEL_SPELL),
]));
let idle = ObjectStore(ObjectFields::from_pairs(&[]));
assert_eq!(
select_targets(CHANNEL_SPELL, None, &channelling, &index),
vec![victim],
"no hops + a live channel object ⇒ the channel object"
);
assert_eq!(
select_targets(
CHANNEL_SPELL,
Some(&ChainHops(vec![hop])),
&channelling,
&index
),
vec![victim],
"…and `<= 1` is unsigned `jbe`: ONE hop still takes the channel path"
);
assert_eq!(
select_targets(
CHANNEL_SPELL,
Some(&ChainHops(vec![hop, victim])),
&channelling,
&index
),
vec![hop, victim],
"two hops outgrow the single-target path and the array wins"
);
assert_eq!(
select_targets(421, Some(&ChainHops(vec![hop])), &channelling, &index),
vec![hop],
"a DIFFERENT spell's kit never takes the channel object"
);
assert!(
select_targets(CHANNEL_SPELL, None, &idle, &index).is_empty(),
"no channel, no hops ⇒ nothing is drawn"
);
}
/// The end-to-end spawn: a play + a filled hop array becomes a beam whose nodes are
/// `caster → t1 → t2`, whose hops carry the reference's staggered windows, and whose expiry is
/// `hops × boltLife`. And the array is **consumed** — a second play the same frame draws nothing.
#[test]
fn a_play_over_a_filled_hop_array_builds_the_staggered_polyline_once() {
let mut app = beam_app();
let (caster, t1, t2) = (unit(&mut app), unit(&mut app), unit(&mut app));
app.world_mut()
.entity_mut(caster)
.insert(ChainHops(vec![t1, t2]));
app.world_mut().write_message(play(caster, CHAIN_SPELL));
app.update();
let live = beams(&mut app);
assert_eq!(live.len(), 1, "one beam, not one per hop");
let (nodes, bolts, expiry, persistent) = &live[0];
assert_eq!(
nodes,
&vec![caster, t1, t2],
"caster → t1 → t2, in wire order"
);
assert_eq!(bolts.len(), 2, "two hops for two targets");
// id 1's row: 1000 ms of life per hop, 300 ms of stagger between them.
assert!(
(bolts[1].0 - bolts[0].0 - 0.3).abs() < 1e-5,
"the hop stagger"
);
assert!(
(bolts[0].1 - bolts[0].0 - 1.0).abs() < 1e-5,
"one second per hop"
);
assert!((expiry - bolts[0].0 - 2.0).abs() < 1e-5, "hops × boltLife");
assert!(!persistent, "a cast-stage proc (flag 0) self-terminates");
// The array is gone — the same law as `0x60db72`, which zeroes the count on every path.
assert!(app.world().entity(caster).get::<ChainHops>().is_none());
app.world_mut().write_message(play(caster, CHAIN_SPELL));
app.update();
assert_eq!(
beams(&mut app).len(),
1,
"the second play has nothing to run"
);
}
/// The consume is immediate, not deferred: two plays in ONE frame must not both see the array.
#[test]
fn two_plays_in_one_frame_share_no_hops() {
let mut app = beam_app();
let (caster, t1) = (unit(&mut app), unit(&mut app));
app.world_mut()
.entity_mut(caster)
.insert(ChainHops(vec![t1]));
app.world_mut().write_message(play(caster, CHAIN_SPELL));
app.world_mut().write_message(play(caster, CHAIN_SPELL));
app.update();
assert_eq!(beams(&mut app).len(), 1, "only the first play consumes");
}
/// The flag is the whole cast/channel split: a **persistent** beam never expires by time and is
/// ended only by the kit reap (the channel teardown's `LightningObject::Stop`), while a one-shot
/// ignores that reap entirely and runs its own clock — the reference only ever publishes a
/// FLAGGED node to the slots the teardown sweeps.
#[test]
fn only_a_persistent_beam_answers_the_kit_reap() {
for persistent in [false, true] {
let mut app = beam_app();
let (caster, t1) = (unit(&mut app), unit(&mut app));
app.world_mut()
.entity_mut(caster)
.insert(ChainHops(vec![t1]));
let mut p = play(caster, CHAIN_SPELL);
p.proc.flag = persistent;
app.world_mut().write_message(p);
app.update();
assert_eq!(beams(&mut app).len(), 1);
assert_eq!(beams(&mut app)[0].3, persistent);
app.world_mut().write_message(SpellKitFx::Reap {
entity: caster,
spell_id: CHAIN_SPELL,
class: FxClass::Hold,
});
app.update();
assert_eq!(
beams(&mut app).len(),
usize::from(!persistent),
"persistent={persistent}: the reap takes the channel beam and spares the cast one"
);
}
}
/// The client's own guards, each a silent return in `0x6ecbd0`: a bare kit push (spell id 0),
/// a zero strand count, and a chain id naming no table row all build nothing — while still
/// consuming the array on the way out.
#[test]
fn the_constructors_guards_build_nothing_but_still_consume() {
for (spell_id, beams_param, effect_id) in [
(0u32, 1u32, 1u32),
(CHAIN_SPELL, 0, 1),
(CHAIN_SPELL, 1, 99),
] {
let mut app = beam_app();
let (caster, t1) = (unit(&mut app), unit(&mut app));
app.world_mut()
.entity_mut(caster)
.insert(ChainHops(vec![t1]));
let mut p = play(caster, spell_id);
p.proc.effect_id = effect_id;
p.proc.beams = beams_param;
app.world_mut().write_message(p);
app.update();
assert!(
beams(&mut app).is_empty(),
"spell {spell_id} / beams {beams_param} / chain {effect_id} draws nothing"
);
assert!(
app.world().entity(caster).get::<ChainHops>().is_none(),
"…and the array is consumed anyway"
);
}
}
/// The DBC's backslash path becomes the lane's `mpq://` URL (lowercased, forward slashes) —
/// the same shape every other BLP load in the client uses.
#[test]
fn the_texture_path_becomes_an_mpq_url() {
assert_eq!(
beam_texture_url("Textures\\SpellChainEffects\\Lightning.blp"),
"mpq://textures/spellchaineffects/lightning.blp"
);
}
}
+3 -2
View File
@@ -156,8 +156,9 @@ pub(super) struct Missile {
}
/// A unit's attach-point world position through the given tag cascade, else its base translation.
/// `None` only when the unit itself is gone.
fn attach_world_pos(
/// `None` only when the unit itself is gone. Shared with the chain-beam lane, whose non-caster
/// endpoints anchor through the very same table and cascade (`0x6ec780`; decision 0955).
pub(super) fn attach_world_pos(
unit: Entity,
tags: impl IntoIterator<Item = u16>,
units: &Query<(&GlobalTransform, Option<&BoneAttach>)>,
+1 -1
View File
@@ -1009,7 +1009,7 @@ pub(super) fn apply_net_updates(
caster,
spell_id,
targets,
} => spell_chain_targets(caster, spell_id, targets, &index),
} => spell_chain_targets(caster, spell_id, targets, &mut commands, &index),
SessionEvent::SpellFailedOther { caster, spell_id } => spell_failed_other(
caster,
spell_id,
+160 -15
View File
@@ -444,6 +444,14 @@ pub(super) fn spell_go(
if casting.get(e).is_ok_and(|c| c.spell_id == spell_id) {
commands.entity(e).remove::<Casting>();
}
// The GO's own chain-hop fill (`0x6e800d`, inside `HandleSpellGo`) — the **second**
// producer of `unit+0xd44`, and the one that makes a non-channelled chain spell draw at
// all. It must precede the CastEvent below: the router plays the cast kit off that event,
// and the kit's chain proc consumes this array the same frame. Named approximation: the
// reference gates this leg on `0x6e4870`'s return, a predicate the §5 could not settle —
// we fill unconditionally, which is harmless because consumption still needs a chain proc
// and because every producer clears before it fills.
fill_chain_hops(caster, e, &hits, commands, index);
cast_events.write(CastEvent {
entity: e,
spell_id,
@@ -692,33 +700,54 @@ pub(super) fn aura_duration(
durations.set(slot, remaining_ms, now_secs);
}
/// The caster's chain-target hop array, filled from a wire target list — the reference's
/// `0x605780` (decision 0955): it **clears before it fills** and **skips any entry equal to the
/// unit's own guid** (`0x6057bf`/`0x6057c9`). Targets not streamed to us drop out here: an endpoint
/// we cannot place has nothing to draw to, which is what the reference's own hidden-hop path
/// amounts to.
///
/// Both producers land here — this is the shared body, not a helper: `0x605780` has exactly two
/// callers image-wide, `SMSG_SPELL_UPDATE_CHAIN_TARGETS`'s handler and `HandleSpellGo`.
fn fill_chain_hops(
caster: u64,
caster_entity: Entity,
targets: &[u64],
commands: &mut Commands,
index: &GuidIndex,
) {
let hops: Vec<Entity> = targets
.iter()
.filter(|&&g| g != caster)
.filter_map(|g| index.0.get(g).copied())
.collect();
commands
.entity(caster_entity)
.try_insert(crate::entities::ChainHops(hops));
}
/// `SMSG_SPELL_UPDATE_CHAIN_TARGETS` → the hop list a **beam** visual runs through (0955).
///
/// The reference parks it on the caster (the growable array at `unit+0xd44`: capacity `+0xd44`,
/// count `+0xd48`, data `+0xd4c`, alloc quantum `+0xd50`), **dropping any entry equal to the
/// caster's own guid** while it fills, and the next chain `CharProc` consumes it once —
/// `0x60db72` zeroes the count on every path. This packet is one of **two** producers: the
/// reference fills the same array from `SMSG_SPELL_GO`'s hit list as well (`0x6e800d` inside its
/// GO handler), which is how a non-channeled chain spell draws at all.
///
/// The beam renderer is the next slice, so this arm resolves and reports the hops rather than
/// parking them — a packet that arrives and is visible in the trace, with no half-built state
/// hanging off a unit waiting for a consumer.
/// count `+0xd48`, data `+0xd4c`, alloc quantum `+0xd50`), and the next chain `CharProc` consumes
/// it once — `0x60db72` zeroes the count on every path. This packet is one of **two** producers:
/// the reference fills the same array from `SMSG_SPELL_GO`'s hit list as well (`0x6e800d` inside
/// its GO handler), which is how a non-channeled chain spell draws at all. vmangos sends this one
/// only for channelled spells (`Spell::SendChannelStart`), so on this server it is the drains'
/// and Mind Flay's producer, and the GO leg is every other chain spell's.
pub(super) fn spell_chain_targets(
caster: u64,
spell_id: u32,
targets: Vec<u64>,
commands: &mut Commands,
index: &GuidIndex,
) {
let known = targets
.iter()
.filter(|&&g| g != caster)
.filter(|g| index.0.contains_key(g))
.count();
debug!(
"net: chain targets for spell {spell_id} by {caster:#x} — {} hop(s), {known} streamed",
"net: chain targets for spell {spell_id} by {caster:#x} — {} hop(s)",
targets.len()
);
if let Some(&e) = index.0.get(&caster) {
fill_chain_hops(caster, e, &targets, commands, index);
}
}
#[cfg(test)]
@@ -888,6 +917,122 @@ mod tests {
assert!(matches!(feed[0], CastBarEdge::Stop), "…with a STOP");
}
/// **Both** producers of the caster's chain-hop array (decision 0955): `SMSG_SPELL_GO`'s own
/// hit list — the leg that makes a non-channelled chain spell draw at all — and the 816 packet.
/// Each drops the caster's own guid and each clears before it fills; unstreamed targets fall
/// out as they resolve. Without this the beam lane is inert no matter how right its geometry is.
#[test]
fn both_wire_producers_fill_the_casters_chain_hop_array() {
use crate::combat_text::CombatTextSpawn;
use crate::creature_anim::Casting;
use crate::entities::ChainHops;
use crate::go_anim::GoLidOpen;
use crate::net::Guid;
use bevy::ecs::system::RunSystemOnce;
let mut app = App::new();
app.add_message::<CastEvent>()
.add_message::<SpellGoTargets>()
.add_message::<CombatTextSpawn>()
.add_message::<GoLidOpen>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<CastBarFeed>()
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::items::Items>();
// A caster and two streamed victims; guid 40 is never streamed to us.
let mut spawn = |guid: u64| {
let e = app
.world_mut()
.spawn((Guid(guid), ObjectStore::default()))
.id();
app.world_mut()
.resource_mut::<GuidIndex>()
.0
.insert(guid, e);
e
};
let (caster, t1, t2) = (spawn(10), spawn(20), spawn(30));
let hops = |app: &App| {
app.world()
.entity(caster)
.get::<ChainHops>()
.map(|h| h.0.clone())
};
// Leg 1 — `HandleSpellGo`'s own fill (`0x6e800d`). The hit list carries the caster itself
// (vmangos includes a self-hit on plenty of spells) and an unstreamed target; both drop.
let (tx, _rx) = crossbeam_channel::unbounded();
app.world_mut()
.run_system_once(
move |mut commands: Commands,
index: Res<GuidIndex>,
casting: Query<&Casting>,
mut cast_events: MessageWriter<CastEvent>,
mut go_targets: MessageWriter<SpellGoTargets>,
self_guid: Res<SelfGuid>,
stores: Query<&mut ObjectStore>,
mut cast_bar: ResMut<CastBarFeed>,
mut pending: ResMut<PendingCast>,
mut queued_melee: ResMut<QueuedMeleeSpell>,
mut text: MessageWriter<CombatTextSpawn>,
mut go_lid: MessageWriter<GoLidOpen>,
mut cooldowns: ResMut<Cooldowns>,
mut items: ResMut<crate::items::Items>| {
let net_commands = crate::net::NetCommands(tx.clone());
spell_go(
10,
421, // Chain Lightning
0,
vec![10, 20, 40, 30],
vec![],
None,
None,
None,
None,
None,
&mut commands,
&index,
&casting,
&mut cast_events,
&mut go_targets,
&self_guid,
&stores,
&mut cast_bar,
&mut pending,
&mut queued_melee,
&mut text,
&mut go_lid,
(&mut cooldowns, None, &mut items, &net_commands),
1,
);
},
)
.unwrap();
assert_eq!(
hops(&app),
Some(vec![t1, t2]),
"the GO fills the array in wire order, minus the caster and the unstreamed target"
);
// Leg 2 — the 816 packet, which is what vmangos sends for a CHANNELLED chain. It clears
// before it fills, so the GO's list above must not survive underneath it.
app.world_mut()
.run_system_once(move |mut commands: Commands, index: Res<GuidIndex>| {
spell_chain_targets(10, 689, vec![30, 10], &mut commands, &index);
})
.unwrap();
assert_eq!(
hops(&app),
Some(vec![t2]),
"clear-before-fill, caster dropped"
);
}
/// The director's stuck-shooting-idle report: click off the target during Auto Shot and the
/// Load/Hold stance never drops. vmangos never sends `SMSG_CANCEL_AUTO_REPEAT` (dead packet
/// class) — a deselect surfaces ONLY as a `SMSG_CAST_RESULT` failure for the cached
+4 -1
View File
@@ -798,7 +798,10 @@ impl Plugin for ParticlePlugin {
// the palette replacement — decision 0813), so that card's transform has to be
// this frame's before births read it.
.after(crate::billboard::face_billboards),
);
)
// The write-order tripwire's re-arm, once the frame's stream has been extracted
// (see `buffer::EffectQuads::cleared_this_frame`).
.add_systems(Last, buffer::clear_effect_frame_flag);
}
}
+139 -1
View File
@@ -218,10 +218,36 @@ pub struct EffectDraw {
/// The frame's shared stream. Cleared at the top of `PostUpdate`'s effect set
/// ([`begin_effect_frame`]), filled by the family systems, copied to the render world in
/// `ExtractSchedule`.
#[derive(Resource, Default)]
#[derive(Resource)]
pub struct EffectQuads {
pub verts: Vec<EffectVertex>,
pub draws: Vec<EffectDraw>,
/// Has [`begin_effect_frame`] run yet **this** frame? A writer that commits while this is
/// `false` ran BEFORE the clear, so everything it pushed is about to be erased — a silent,
/// total loss of that lane's frame, with its arithmetic entirely correct.
///
/// This is the shape of B161: the chain beam declared its pose dependencies but not
/// `.after(begin_effect_frame)`, the clear carried one extra `.after` the beam did not, and
/// the beam became runnable first. Nothing warned; the lane simply never drew. The ordering
/// is unenforceable by the type system (any writer can forget an edge), so the protocol is
/// asserted here instead — [`Self::commit`] refuses a pre-clear write in debug builds, and
/// [`clear_effect_frame_flag`] arms it again in `Last`.
cleared_this_frame: bool,
}
impl Default for EffectQuads {
fn default() -> Self {
Self {
verts: Vec::new(),
draws: Vec::new(),
// Armed, not tripped: a fixture that commits into a bare stream (most of the family's
// unit tests) is testing geometry, not schedule order, and has no clear to run. The
// `Last` re-arm is what makes the flag mean anything, and it only exists in an app
// carrying `ParticlePlugin` — where the very first frame's clear precedes any writer
// regardless, and a mis-ordered writer trips on frame two and every frame after.
cleared_this_frame: true,
}
}
}
/// Everything about one draw except its vertex range — the argument bundle `commit_quads` /
@@ -262,6 +288,12 @@ impl EffectQuads {
}
fn commit(&mut self, start: u32, topology: EffectTopology, spec: EffectDrawSpec) {
debug_assert!(
self.cleared_this_frame,
"effect-stream write before `begin_effect_frame`: this draw is about to be erased. \
The producing system needs `.after(crate::particles::buffer::begin_effect_frame)` \
(see `EffectQuads::cleared_this_frame` — this is B161's failure mode)."
);
let end = self.verts.len() as u32;
if end > start {
self.draws.push(EffectDraw {
@@ -286,6 +318,13 @@ impl EffectQuads {
pub fn begin_effect_frame(mut quads: ResMut<EffectQuads>) {
quads.verts.clear();
quads.draws.clear();
quads.cleared_this_frame = true;
}
/// Disarm the write-order tripwire for the next frame — `Last`, after the render world has
/// extracted this frame's stream. See [`EffectQuads::cleared_this_frame`].
pub fn clear_effect_frame_flag(mut quads: ResMut<EffectQuads>) {
quads.cleared_this_frame = false;
}
#[cfg(test)]
@@ -340,4 +379,103 @@ mod tests {
EffectBlend::Mod2x
);
}
/// **B161, as an executable record.** The chain beam shipped with correct arithmetic and no
/// `.after(begin_effect_frame)` edge, and drew nothing at all — for weeks, silently.
///
/// The mechanism is not "the writers race": it is that the clear carries one dependency the
/// writer does not (`face_billboards`), so the writer becomes *runnable first* and the clear
/// lands on top of it. This pair pins both halves — the failure mode, and the edge that fixes
/// it. The graph below is the real registration shape of `ParticlePlugin` (the clear) and
/// `EntitiesPlugin` (the beam sim), transcribed.
mod write_order {
use super::*;
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
struct Place;
fn joint_palette() {}
fn finalize_rigs() {}
fn face_billboards() {}
fn writer(mut quads: ResMut<EffectQuads>) {
let start = quads.begin();
for _ in 0..4 {
quads.verts.push(EffectVertex {
pos: [0.0; 3],
uv: [0.0; 2],
color: [1.0; 4],
});
}
quads.commit_quads(
start,
EffectDrawSpec {
cam: Entity::PLACEHOLDER,
texture: AssetId::default(),
blend: EffectBlend::Add,
fog: EffectFog::Off,
anchor: Vec3::ZERO,
bias: 0.0,
raster_bias: 0,
main_entity: Entity::PLACEHOLDER,
light: None,
},
);
}
/// `edged`: whether the writer declares the load-bearing `.after(begin_effect_frame)`.
fn run(edged: bool) -> App {
let mut app = App::new();
app.init_resource::<EffectQuads>();
app.add_systems(
PostUpdate,
(joint_palette, finalize_rigs, face_billboards)
.chain()
.in_set(Place),
);
let w = writer
.in_set(Place)
.after(joint_palette)
.after(finalize_rigs);
app.add_systems(
PostUpdate,
if edged {
w.after(begin_effect_frame)
} else {
w
},
);
app.add_systems(
PostUpdate,
begin_effect_frame
.in_set(Place)
.after(joint_palette)
.after(finalize_rigs)
// The extra dependency the beam sim lacked — the whole mechanism.
.after(face_billboards),
);
app.add_systems(Last, clear_effect_frame_flag);
// Two frames: the first arms the tripwire (a bare stream defaults to armed, and the
// `Last` re-arm has not run yet), the second is any ordinary frame.
app.update();
app.update();
app
}
/// With the edge, the writer's draw survives the frame and reaches extract.
#[test]
fn the_edge_keeps_the_draw() {
let app = run(true);
let quads = app.world().resource::<EffectQuads>();
assert_eq!(quads.draws.len(), 1, "the draw must survive to extract");
assert_eq!(quads.verts.len(), 4);
}
/// Without it, the write precedes the clear — the tripwire that now names the bug.
#[test]
#[should_panic(expected = "effect-stream write before `begin_effect_frame`")]
fn without_the_edge_the_tripwire_names_it() {
let _ = run(false);
}
}
}
+39 -13
View File
@@ -16,9 +16,11 @@
//! - [`WarmPass`] + `spawn_menagerie` — the warm pass: one tiny rig per reachable pipeline
//! variant — the model lane with its shard-rung and far-side twins, and the sky/water lanes
//! (celestial, stars, clouds, gradient dome, WMO skybox, liquid; decision 0945 widened 0837's
//! model-only scope) — parented to the world camera, spawned when the entry cover rises; the
//! model-only scope) — parented to the world camera, spawned a few frames AFTER the entry
//! cover rises (so the cover is on the glass before the burst, not racing it — 0962); the
//! loading screen's clear condition holds on [`WarmPass::satisfied`] until the pipeline cache
//! drains (10 s backstop, 0737's rule), then the menagerie despawns. Captures skip it.
//! drains (10 s backstop, 0737's rule), then the menagerie despawns (roots only — recursion
//! takes the twin booth's children, 0962). Captures skip it.
//! Booth twins ride a real booth's layer (samples=1, 0938) AND the pass's own twin booth
//! ([`crate::portrait::spawn_warm_booth`] — the custom-projection view key real bakes install;
//! decision 0958), and [`warm_effect_lane`] pushes the `wow_effect` lane's whole key cross
@@ -252,6 +254,8 @@ pub(crate) struct WarmPass {
/// The 1×1 stand-in texture [`warm_effect_lane`]'s draws bind while the pass runs (a strong
/// handle so the asset lives exactly as long as the pass; `None` = the lane isn't warming).
effect_tex: Option<Handle<Image>>,
/// Consecutive covered+in-world frames seen while idle (see [`WARM_COVER_PRESENT_FRAMES`]).
covered_frames: u32,
}
impl WarmPass {
@@ -267,6 +271,13 @@ const WARM_SETTLE_SECS: f32 = 0.25;
/// 0737's rule: never hold a cover unbounded. A timeout fires the tripwire-adjacent warn and
/// releases; the remaining compiles land live (the pre-0837 world, once, with a named cause).
const WARM_TIMEOUT_SECS: f32 = 10.0;
/// Covered frames the pass waits before spawning the menagerie, so the compile burst lands
/// behind a cover that is ON THE GLASS. On world entry the raise frame still renders the glue
/// (the state flips a frame later), so the FIRST covered+in-world frame is also the first frame
/// the cover can draw — spawning then puts the whole synchronous burst in that same frame's
/// render, and what stays on screen for the burst is the previous present: the frozen character
/// screen (the director's report). Two extra frames ≈ 33 ms of cover; the burst is seconds cold.
const WARM_COVER_PRESENT_FRAMES: u32 = 3;
#[allow(clippy::too_many_arguments)] // a Bevy system: each param is one resource, the app's convention
fn run_warm_pass(
@@ -277,7 +288,7 @@ fn run_warm_pass(
state: Res<State<ClientState>>,
time: Res<Time>,
camera: Query<Entity, With<crate::player::WorldCamera>>,
rigs: Query<Entity, With<WarmRig>>,
rigs: Query<(Entity, Option<&ChildOf>), With<WarmRig>>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<WowModelMaterial>>,
mut lanes: WarmLanes,
@@ -293,9 +304,8 @@ fn run_warm_pass(
warm.done = true;
warm.spawned_at = None;
warm.effect_tex = None;
for e in &rigs {
commands.entity(e).despawn();
}
warm.covered_frames = 0;
despawn_rigs(&mut commands, &rigs);
return;
}
// Captures boot straight in-world, deterministic by construction — no menagerie in a shot.
@@ -307,12 +317,18 @@ fn run_warm_pass(
let Some(spawned) = warm.spawned_at else {
// The cover just rose (or the world just became live under one): raise the gate and
// spawn the menagerie once the camera + shared light exist (both are entry-frame-early;
// until they do, the gate holds the cover, which is exactly right).
// until they do, the gate holds the cover, which is exactly right) — and once the cover
// has had [`WARM_COVER_PRESENT_FRAMES`] frames to reach the glass, so the burst is
// actually hidden (the char-screen freeze, 0962).
warm.done = false;
let Ok(cam) = camera.single() else { return };
let Some(light) = shared_light.as_ref() else {
return;
};
warm.covered_frames += 1;
if warm.covered_frames < WARM_COVER_PRESENT_FRAMES {
return;
}
warm.spawned_at = Some(now);
// The twin booth (0958): the custom-projection view key space real bakes use — the real
// booths warm the placeholder-Perspective class, this camera the NONSTANDARD one. It is
@@ -357,20 +373,30 @@ fn run_warm_pass(
if now - spawned >= WARM_SETTLE_SECS && pending == 0 {
warm.done = true;
warm.effect_tex = None;
for e in &rigs {
commands.entity(e).despawn();
}
despawn_rigs(&mut commands, &rigs);
info!("pipeline warm: drained in {:.2}s", now - spawned);
} else if now - spawned >= WARM_TIMEOUT_SECS {
warm.done = true;
warm.effect_tex = None;
for e in &rigs {
commands.entity(e).despawn();
}
despawn_rigs(&mut commands, &rigs);
warn!("pipeline warm: TIMED OUT with {pending} pipelines pending — cover released");
}
}
/// Tear the pass down by despawning only its ROOT entities. `despawn` is recursive, and the twin
/// booth's rigs are *children* of the twin booth camera — itself a `WarmRig` — so despawning
/// every query row queues the children twice (once explicitly, once via the parent's recursion):
/// a warn per child on the teardown frame (0962). Children of a live camera (the world camera,
/// a real booth) still get their explicit despawn.
fn despawn_rigs(commands: &mut Commands, rigs: &Query<(Entity, Option<&ChildOf>), With<WarmRig>>) {
for (e, child_of) in rigs {
if child_of.is_some_and(|c| rigs.contains(c.parent())) {
continue;
}
commands.entity(e).despawn();
}
}
/// The **effect-lane** warm writer (decision 0958 — the 07:45 log's [831], the selection ring's
/// first-target stall). `wow_effect` is a custom `SpecializedRenderPipeline` lane, not a
/// `MaterialPlugin` one, so no menagerie *entity* can reach it: its pipelines exist only when a
+9 -2
View File
@@ -30,7 +30,7 @@ use crate::schedule::WorldStage;
use crate::ui_script::PointerOverUi;
mod arc;
mod camera;
pub(crate) mod camera;
mod follow;
mod gait;
@@ -131,6 +131,7 @@ impl Plugin for PlayerPlugin {
app.insert_resource(cam);
}
follow::plugin(app);
app.init_resource::<camera::LookConfig>();
app.add_systems(Startup, setup::setup_player.after(AssetSet::Open))
// The world camera renders only when the world can be seen (decision 0540): in world,
// or under the opaque loading screen (whose covered render is what compiles the
@@ -265,7 +266,11 @@ fn control(
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
// Nested into one param to stay within Bevy's 16-element system-param tuple limit.
mouse: (Res<AccumulatedMouseMotion>, Res<AccumulatedMouseScroll>),
mouse: (
Res<AccumulatedMouseMotion>,
Res<AccumulatedMouseScroll>,
Res<camera::LookConfig>,
),
// The net bridge, bundled into one param (16-param limit): the outbound command channel + the
// inbound teleport/worldport messages `apply_net_updates` wrote earlier this frame
// (WorldStage::Net), + the sheath-setter queue (the Z toggle's request — decision 0080).
@@ -373,6 +378,7 @@ fn control(
};
let (mut window, mut cursor_opts) = window.into_inner();
let (mouse_motion, mouse_scroll) = (&mouse.0, &mouse.1);
let invert_pitch = mouse.2.invert_pitch;
let (move_speed, capsule, cam_probe, pointer_over_ui, inspect, ui_capture, click_consumed) = (
&speed_capsule.0,
&speed_capsule.1 .0,
@@ -453,6 +459,7 @@ fn control(
&mut world_clicks.2,
left_click,
right_click,
invert_pitch,
);
// A stun freezes the BODY, not the view. The look session has already moved `cam.yaw` (and, on
// a right-drag, coupled `face_yaw = cam.yaw`); putting the aim back leaves the camera orbiting
+13 -2
View File
@@ -47,6 +47,15 @@ const CAM_ZOOM_STEP: f32 = 1.0;
const CAM_MOVE_SPEED: f32 = 8.33;
/// Mouse-look sensitivity, radians of camera rotation per pixel of mouse motion.
const LOOK_SENSITIVITY: f32 = 0.003;
/// The mouse-look player knobs (decision 0961): `mouseInvertPitch` is 1.12's own Interface
/// Options checkbox (UIOptionsFrame.lua index 1, CVar-backed), settable from the Options
/// window's Controls page through the CVar store (0954). Inverted, moving the mouse up pitches
/// the camera down — the delta.y term flips sign at the one apply site, both drag styles alike.
#[derive(Resource, Default)]
pub(crate) struct LookConfig {
pub(crate) invert_pitch: bool,
}
/// Camera pitch clamp (radians) — **VERIFIED ±89.00°** (`WoW.exe` `0x8089d8`/`0x8089dc` =
/// 1.5533430576 rad; the pitch integrate `FUN_00510120`, wow-re `follow-camera`). A single uniform
/// clamp at every zoom level — the reference has **no** distinct first-person look-down limit.
@@ -223,6 +232,7 @@ pub(super) fn run_look_session(
world_right_press: &mut MessageWriter<WorldRightPress>,
left_click: &mut Option<f32>,
right_click: &mut Option<f32>,
invert_pitch: bool,
) {
// The right button's DOWN edge, before any click-vs-drag classification — the reference's
// WorldFrame OnMouseDown fires at the press whether it becomes a click or a turn. It belongs
@@ -334,8 +344,9 @@ pub(super) fn run_look_session(
if let Some(active) = rig.look {
let delta = mouse_motion.delta;
cam.yaw -= delta.x * LOOK_SENSITIVITY;
cam.pitch =
(cam.pitch - delta.y * LOOK_SENSITIVITY).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT);
// `mouseInvertPitch` flips only the pitch axis (the 1.12 checkbox's whole meaning).
let dy = if invert_pitch { -delta.y } else { delta.y };
cam.pitch = (cam.pitch - dy * LOOK_SENSITIVITY).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT);
if active == LookButton::Right || both_buttons {
*face_yaw = cam.yaw;
}
+7 -2
View File
@@ -62,6 +62,7 @@ pub(super) fn select_on_click(
occlusion: Res<PickOcclusion>,
mut greeting: MessageWriter<crate::sound::NpcGreetingRequest>,
ground: Res<crate::ui_action::SpellTargeting>,
click_cfg: Res<ClickConfig>,
) {
// Drain the frame's clicks; act only if there was one and the inspector isn't holding left-click.
let clicked = clicks.read().last().is_some();
@@ -108,8 +109,10 @@ pub(super) fn select_on_click(
// press is consumed for the drop/dismiss (input.rs's `would_drop`), so a payload
// normally never reaches this arm at all — the gate still holds the nothing-leg law
// for the residual GameObject-hover case (`Object` pick, unit arm empty).
// `deselectOnClick` (0961) gates the whole arm: "Sticky Targeting" checked = the CVar
// at 0 = an empty-world click keeps the target (1.12's own inverted checkbox).
_ => {
if !payload_held.0 || occlusion.distance.is_finite() {
if click_cfg.deselect_on_click && (!payload_held.0 || occlusion.distance.is_finite()) {
clear(&mut selection, &net, engaged);
}
}
@@ -644,10 +647,12 @@ fn interact_command(
}
// Repair and Cast are UI-overlay modes only; Inspect and the data-named GameObject
// cursors (Mail/Mine/GatherHerbs/PickLock) belong to the GO branch, which routes above
// and never reaches this NPC-service dispatch — a unit never classifies to any of these.
// and never reaches this NPC-service dispatch — a unit never classifies to any of
// these. LootAll is loot-only (the corpse route above), never a service.
CursorKind::Point
| CursorKind::Attack
| CursorKind::Skin
| CursorKind::LootAll
| CursorKind::Repair
| CursorKind::Inspect
| CursorKind::Mail
+52 -13
View File
@@ -9,8 +9,9 @@
//! banker/auctioneer → **Buy**; REPAIR (0x4000) is *never consulted* — a repair-only unit falls
//! through to the attack/clear leg (real repairers all carry VENDOR too).
//! - otherwise **loot / skin / attack** keyed on state: dead + `UNIT_DYNFLAG_LOOTABLE` →
//! **Pickup** — the loot leg's base mode is `mode = 8 + (keyDown(0) ? 8 : 0)` (`0x48252c`), i.e.
//! Pickup(8), becoming LootAll(16) only while the auto-loot modifier key is *held*; dead +
//! **Pickup or LootAll** — the loot leg's mode is `8 + (keyDown(0) ? 8 : 0)` (`0x48252c`):
//! the triple-pouch LootAll(16) while the auto-loot modifier is held, generalized since 0961's
//! auto-loot to the EFFECTIVE state (`autoLootDefault` XOR shift — [`loot_cursor`]); dead +
//! `UNIT_FLAG_SKINNABLE` → Skin; alive and attackable → Attack.
//! - **`Unable*` (grayed) by a different gate per mode** (byte-verified): NPC services gray beyond
//! **5.5556 yd** (`0x482320`); attack beyond a fixed **10.45 yd** (`0x4826a7`); skin outside the
@@ -23,10 +24,10 @@
//! *Interim*: the reference's interactability predicate (`CGUnit::CanInteract 0x606880`) isn't
//! fully derived; we approximate it as "has service flags and isn't attack-worthy (reaction ≥
//! neutral)". Attackability approximates the PvP/attack matrix as "reaction rank ≤ neutral" —
//! the same approximation the ring's player branch documents. The auto-loot modifier-key split
//! (Pickup vs LootAll, `0x41f8f0`) waits on auto-loot existing at all — until then the base Pickup
//! is the only mode the loot leg can honestly show. (The questgiver bit's own quest-status gate,
//! `0x5df490`, used to be listed here as unmodelled; it is modelled now — [`questgiver_has_quest`].)
//! the same approximation the ring's player branch documents. (The questgiver bit's own
//! quest-status gate, `0x5df490`, and the auto-loot Pickup/LootAll split, `0x41f8f0`, both used
//! to be listed here as unmodelled; both are modelled now — [`questgiver_has_quest`],
//! [`loot_cursor`].)
use bevy::prelude::*;
@@ -44,9 +45,13 @@ pub(crate) enum CursorKind {
Point,
Attack,
Speak,
/// Pickup(8) — the vendor's pouch AND the loot leg's base mode (LootAll(16) is this same leg
/// with the auto-loot modifier held — deferred until auto-loot exists).
/// Pickup(8) — the vendor's pouch AND the loot leg's base mode.
Pickup,
/// LootAll(16) — the loot leg's triple pouch while the EFFECTIVE auto-loot is on
/// (`autoLootDefault` XOR the held shift, 0961's own rule — in 1.12 the held key alone was
/// the whole mechanism, `8 + (keyDown(0) ? 8 : 0)` @ `0x48252c`). Loot only: the vendor
/// pouch never triples.
LootAll,
/// Interact(5) — the generic gear. A GameObject base type's cursor when it carries no
/// data-named cursor (a door, button, chest, keyed/keyless lock, fishing, …); also the
/// innkeeper service.
@@ -91,6 +96,7 @@ impl CursorKind {
CursorKind::Attack => "Attack",
CursorKind::Speak => "Speak",
CursorKind::Pickup => "Pickup",
CursorKind::LootAll => "LootAll",
CursorKind::Interact => "Interact",
CursorKind::Buy => "Buy",
CursorKind::Inspect => "Inspect",
@@ -450,6 +456,18 @@ fn service_cursor(service: u32, quest_status: Option<u32>) -> Option<CursorKind>
}
}
/// The loot leg's mode split (`8 + (keyDown(0) ? 8 : 0)` @ `0x48252c`): the single pouch, or
/// the triple LootAll while the EFFECTIVE auto-loot is on — the 0961 rule (`autoLootDefault`
/// XOR the held modifier) so the cursor always announces what the click will actually do. In
/// 1.12 the held key alone *was* the effective state (no CVar existed).
fn loot_cursor(auto_loot: bool, shift_held: bool) -> CursorKind {
if auto_loot != shift_held {
CursorKind::LootAll
} else {
CursorKind::Pickup
}
}
/// Resolve this frame's [`WorldCursor`] from the hovered unit — the reference's classifier order:
/// interactable-NPC service ladder, else loot/skin/attack by state, each grayed by its own range
/// gate. No hover (or anything unresolvable) → Point.
@@ -479,6 +497,9 @@ pub(super) fn classify_cursor(
// The QUESTGIVER leg's gate reads the per-guid `SMSG_QUESTGIVER_STATUS` store — see
// [`questgiver_has_quest`].
quest: Res<crate::ui_quest::QuestGiver>,
// The loot leg's Pickup/LootAll split (0965): the auto-loot knob (0961) + the live shift.
loot_cfg: Res<crate::ui_loot::LootConfig>,
keys: Res<ButtonInput<KeyCode>>,
) {
// A **highlightable** GameObject shows its **data-driven** cursor (wow-re cursor-system §4): a
// mailbox's Mail, a plaque's Inspect, a vein's Mine / herb's GatherHerbs / picked lock's PickLock
@@ -553,11 +574,12 @@ pub(super) fn classify_cursor(
let dead = store.0.unit_is_dead();
if dead {
if store.0.unit_lootable() {
// The loot base mode is Pickup(8) — `8 + (keyDown(0) ? 8 : 0)` @ `0x48252c`;
// LootAll waits on auto-loot. Gray by the byte-verified gate (inline in
// `CanLootNow 0x5ec110`): the same melee interact reach as skin — ~5 yd vs a
// normal mob (director-measured, confirmed).
return Some((CursorKind::Pickup, !in_melee));
// The loot mode split `8 + (keyDown(0) ? 8 : 0)` @ `0x48252c`, over 0961's
// effective auto-loot ([`loot_cursor`]). Gray by the byte-verified gate
// (inline in `CanLootNow 0x5ec110`): the same melee interact reach as skin —
// ~5 yd vs a normal mob (director-measured, confirmed).
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
return Some((loot_cursor(loot_cfg.auto_loot, shift), !in_melee));
}
// The skin leg's SECOND precondition, byte-verified: the flag alone is not enough —
// the reference also requires the learn-time latch `[0xb700e4 + 4×isPlayerTarget]` to
@@ -610,6 +632,23 @@ mod tests {
use super::*;
use benilla_protocol::messages::dialog_status;
/// The loot pouch follows the EFFECTIVE auto-loot (0961: setting XOR shift) — the cursor
/// and the click can never disagree about what a pick will do.
#[test]
fn the_loot_pouch_triples_with_the_effective_auto_loot() {
assert_eq!(loot_cursor(false, false), CursorKind::Pickup);
assert_eq!(loot_cursor(true, false), CursorKind::LootAll);
// Shift inverts BOTH ways (era's AUTOLOOTTOGGLE; vanilla's whole mechanism).
assert_eq!(loot_cursor(false, true), CursorKind::LootAll);
assert_eq!(loot_cursor(true, true), CursorKind::Pickup);
// The grayed twin rides the same stem.
let far = WorldCursor {
kind: CursorKind::LootAll,
unable: true,
};
assert_eq!(far.stem(), "UnableLootAll");
}
#[test]
fn stems_name_the_shipped_blps() {
let attack = WorldCursor {
+20
View File
@@ -162,12 +162,32 @@ pub(crate) struct SelectionRadius(pub(crate) f32);
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct TargetUpdate;
/// The click-behavior player knob (decision 0961): `deselectOnClick` is 1.12's own CVar behind
/// the "Sticky Targeting" Interface Options checkbox (inverted there AND in era — checked means
/// the CVar at 0). Default true = the reference default: an empty-world click clears the
/// target, which is exactly the behavior [`click::select_on_click`] shipped with (0571/0574's
/// terrain/nothing legs); the knob only makes its gate player-settable through the CVar store
/// (0954).
#[derive(Resource)]
pub(crate) struct ClickConfig {
pub(crate) deselect_on_click: bool,
}
impl Default for ClickConfig {
fn default() -> Self {
Self {
deselect_on_click: true,
}
}
}
/// Targeting: click-to-select + the ground selection ring.
pub(crate) struct TargetPlugin;
impl Plugin for TargetPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<Selection>()
.init_resource::<ClickConfig>()
.init_resource::<Hovered>()
.init_resource::<HoveredObject>()
.init_resource::<PickOcclusion>()
+10 -8
View File
@@ -926,14 +926,16 @@ fn release_placement(
/// The terrain base material (the `TerrainExtension` does the real work).
///
/// **Single-sided, backface-culled** — the ground is see-through from underneath, exactly as the
/// real client renders it (B193: testers see through 1.12.1's terrain from below, the way a WMO
/// exterior reads from inside a cave; we drew it solid). The reference never sets `EGxRs 0x14`
/// (`GL_CULL_FACE`) in its terrain-chunk pass `0x684510`/`0x6beb50`, so terrain inherits the device
/// baseline `0x14 = 1` written by `0x593bf0` — culling ON. Only the passes that *want* two-sided
/// geometry clear it inside their own `Push`/`PopRenderState` bracket: the four liquid passes, and
/// the WDL low-detail tile render `0x6bd780` (cull 0 at `0x6bd79d`) — which is why `wdl.rs` stays
/// `cull_mode: None` while this one culls. `double_sided: true` here was a Bevy default that no
/// decision ever stood behind.
/// real client renders it (decision 0960; B193: testers see through 1.12.1's terrain from below, the
/// way a WMO exterior reads from inside a cave; we drew it solid). The reference never sets
/// `EGxRs 0x14` (`GL_CULL_FACE`) in its terrain-chunk pass `0x684510`/`0x6beb50`, so terrain inherits
/// the device baseline `0x14 = 1` written by `0x593bf0` — culling ON, and wow-re's census of all 39
/// `0x14` setters proves none leaks in unbracketed ahead of the terrain drain. Only the passes that
/// *want* two sides clear it inside their own `Push`/`PopRenderState` bracket: the four liquid
/// passes, and the WDL mesh `0x6bd780` (cull 0 at `0x6bd79d`) — which is why `wdl.rs` stays
/// `cull_mode: None` while this one culls. Measured, not only derived: in the reference's own
/// apitrace all 103 MCNK-format draws run cull-enabled and all 20 WDL draws cull-disabled.
/// `double_sided: true` here was a Bevy default that no decision ever stood behind.
///
/// Rests on the winding: `terrain_fans_wind_ccw_seen_from_above` (benilla-formats) pins every MCNK
/// fan CCW-from-above in WoW space, and `transform_is_a_proper_rotation` pins the WoW→Bevy map at
+44 -1
View File
@@ -276,6 +276,18 @@ impl LootState {
#[derive(Resource, Default)]
pub(crate) struct LootErrors(pub Vec<u8>);
/// The loot player knob (decision 0961): `autoLootDefault` — era's Controls-page checkbox (no
/// 1.12 CVar exists; vanilla only had the shift-click), settable from the Options window
/// through the CVar store (0954). The reference implements auto-loot ENGINE-side (era's own
/// Lua never reads this CVar outside its settings page), and so do we: [`feed_loot`] picks
/// every row itself at the open edge. A held SHIFT inverts the setting — era's
/// `AUTOLOOTTOGGLE` modified click, default SHIFT (Bindings_Vanilla.xml l.1467), the same
/// gesture that WAS vanilla's whole auto-loot.
#[derive(Resource, Default)]
pub(crate) struct LootConfig {
pub(crate) auto_loot: bool,
}
/// The client-local **loot-target latch** — the mirror of the real client's `[player+0x1d28]`
/// guid (wow-re `loot-anim-leg.md`, the 2026-07-18 §5, decision 0515): set the instant
/// `CMSG_LOOT` goes out (`0x5df253`, the same site that client-predicts the kneel before any
@@ -305,6 +317,7 @@ impl Plugin for UiLootPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<LootState>()
.init_resource::<LootErrors>()
.init_resource::<LootConfig>()
.init_resource::<LootLatch>()
.add_systems(
Update,
@@ -541,6 +554,9 @@ fn feed_loot(
mut errors: ResMut<LootErrors>,
mut chat: ResMut<crate::ui_chat::ChatLog>,
mut last: Local<Option<LootSnapshot>>,
cfg: Res<LootConfig>,
keys: Res<ButtonInput<KeyCode>>,
mut pickup: MessageWriter<crate::sound::LootPickupSound>,
) {
let Some(mut script) = script else {
return;
@@ -569,7 +585,34 @@ fn feed_loot(
}
script.set_loot(fresh.clone());
match (&*last, &fresh) {
(None, Some(_)) => script.fire_event("LOOT_OPENED", vec![]),
(None, Some(snap)) => {
script.fire_event("LOOT_OPENED", vec![]);
// era's engine-side auto-loot ([`LootConfig`]): the knob decides, a held SHIFT
// inverts it, and the client "clicks" every row itself at the open edge — the
// same autostore/coin sends a hand pick makes, pickup sounds included. A refusal
// (inventory full) keeps its row and the window simply stays; emptying it fires
// the existing last-row auto-release.
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
if cfg.auto_loot != shift {
for index in 1..=snap.rows.len() as u32 {
match loot.action_at(index) {
Some(LootAction::Money) => {
let _ = commands.0.send(ClientCommand::LootMoney);
}
Some(LootAction::Item {
wire_slot,
display_id,
}) => {
let _ = commands
.0
.send(ClientCommand::AutostoreLootItem { slot: wire_slot });
pickup.write(crate::sound::LootPickupSound { display_id });
}
None => {}
}
}
}
}
// A content change while open (async name landed, a row removed, coin cleared) → repaint,
// keeping the current page (LOOT_UPDATE, the merchant's MERCHANT_UPDATE twin — this replaces
// Blizzard's per-button LOOT_SLOT_CLEARED optimization with a full re-snapshot, exactly as
+250 -82
View File
@@ -148,16 +148,19 @@ fn clicking_a_row_moves_the_selection_and_the_page_title() {
}
/// Both close spellings — the red Close and the corner X — hide the window on the shared
/// igMainMenuClose kit, and the Defaults button (nothing to default this slice) reads disabled.
/// igMainMenuClose kit; a page WITHOUT rows keeps Defaults disabled (Controls has rows since
/// 0961, so the rowless check moved to Interface).
#[test]
fn both_close_buttons_hide_the_window_and_defaults_is_disabled() {
let mut s = harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(
!s.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.unwrap(),
"Defaults is dead until the pages slice brings rows to default"
"Defaults is dead on a page with no rows"
);
let _ = s.take_sounds();
s.run("OptionsFrameCloseButton:Click()").unwrap();
@@ -279,7 +282,7 @@ fn the_audio_page_reads_the_cvar_table_on_select() {
.unwrap());
// Off to a ROWLESS page: body hidden, Defaults disabled again.
s.run("OptionsFrameCategoryListRowControls:Click()")
s.run("OptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyAudio:IsVisible()")
@@ -411,14 +414,13 @@ fn defaults_resets_the_audio_page_to_registered_defaults() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Selecting Graphics shows ITS page body (0959) with both 1.12 sliders reading the table:
/// uiScale on the 0.64..1.0 panel range with the percent readout, farclip on 177..777 with the
/// raw-yards readout. The swap works both ways — Audio's body takes over when clicked.
/// Selecting Graphics shows ITS page body (0959; one row since the farclip retirement, 0961)
/// with the uiScale slider reading the table on the 0.64..1.0 panel range with the percent
/// readout. The swap works both ways — Audio's body takes over when clicked.
#[test]
fn the_graphics_page_reads_the_cvar_table_on_select() {
let mut s = audio_harness();
s.set_cvar_host("uiScale", "0.8");
s.set_cvar_host("farclip", "297");
let s = harness_on(s);
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowGraphics:Click()")
@@ -442,23 +444,11 @@ fn the_graphics_page_reads_the_cvar_table_on_select() {
.unwrap(),
"80%"
);
assert!(s
.eval::<bool>(
"return math.abs(OptionsFrameContainerBodyGraphicsRowFarclipControlSlider:GetValue() - 297) < 0.001"
)
.unwrap());
// The label is the 1.12 GlobalStrings' own.
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyGraphicsRowFarclipControlValue:GetText()"
)
.unwrap(),
"297"
);
// The labels are the 1.12 GlobalStrings' own.
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyGraphicsRowFarclipLabel:GetText()")
s.eval::<String>("return OptionsFrameContainerBodyGraphicsRowUiScaleLabel:GetText()")
.unwrap(),
"Terrain Distance"
"UI Scale"
);
// The swap, the other way: Audio in, Graphics out.
@@ -472,11 +462,12 @@ fn the_graphics_page_reads_the_cvar_table_on_select() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The Graphics sliders snap to the 1.12 panel grids and write clean short strings: farclip's
/// grid is 177+n*60 — ANCHORED AT THE MINIMUM, so 300 lands on 297, not the multiple-of-60
/// 300 — and uiscale's is the 0.01 percent grid. Selecting the page queues nothing.
/// The uiScale row DEFERS (0961, era CommitFlag.Apply transcribed): moves snap to the 1.12
/// 0.01 grid and update the readout, but the CVar does not move — the Apply button appears
/// instead, commits the pending value on click, and disappears. Dragging back onto the
/// committed value clears the pending without a commit (era's IsModified).
#[test]
fn the_graphics_sliders_snap_to_the_1_12_grids_and_write_clean_strings() {
fn the_ui_scale_slider_defers_to_the_apply_button() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowGraphics:Click()")
@@ -485,40 +476,14 @@ fn the_graphics_sliders_snap_to_the_1_12_grids_and_write_clean_strings() {
s.take_cvar_changes().is_empty(),
"reading the table on select must not write it back"
);
// farclip: a drag to 300 snaps DOWN the min-anchored grid to 297.
s.run("OptionsFrameContainerBodyGraphicsRowFarclipControlSlider:SetValue(300)")
.unwrap();
assert!(s
.eval::<bool>(
"return math.abs(OptionsFrameContainerBodyGraphicsRowFarclipControlSlider:GetValue() - 297) < 0.001"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyGraphicsRowFarclipControlValue:GetText()"
)
.unwrap(),
"297"
);
assert_eq!(
s.take_cvar_changes(),
vec![("farclip".to_string(), "297".to_string())]
assert!(
!s.eval::<bool>("return OptionsFrameApplyButton:IsVisible()")
.unwrap(),
"no pending edit, no Apply button"
);
// The stepper walks one 60-yard step up the same grid, on the era kit.
let _ = s.take_sounds();
s.run("OptionsFrameContainerBodyGraphicsRowFarclipControlForward:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("farclip".to_string(), "357".to_string())]
);
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into())));
// uiScale: off-grid 0.787 snaps to 0.79, percent readout, two-decimal clean string.
// Off-grid 0.787 snaps to 0.79 and the readout follows — but NOTHING queues; the Apply
// button exists now (era shows and enables it together).
s.run("OptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:SetValue(0.787)")
.unwrap();
assert_eq!(
@@ -528,37 +493,88 @@ fn the_graphics_sliders_snap_to_the_1_12_grids_and_write_clean_strings() {
.unwrap(),
"79%"
);
assert!(
s.take_cvar_changes().is_empty(),
"a deferred row must not write the CVar on the move"
);
assert!(s
.eval::<bool>("return OptionsFrameApplyButton:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameApplyButton:IsEnabled()")
.unwrap());
// The stepper still moves the slider (and plays its kit) — still nothing queues.
let _ = s.take_sounds();
s.run("OptionsFrameContainerBodyGraphicsRowUiScaleControlForward:Click()")
.unwrap();
assert!(s.take_cvar_changes().is_empty());
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into())));
// Apply commits the LAST pending value, once, and the button goes away.
s.run("OptionsFrameApplyButton:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("uiScale".to_string(), "0.79".to_string())]
vec![("uiScale".to_string(), "0.8".to_string())]
);
assert!(!s
.eval::<bool>("return OptionsFrameApplyButton:IsVisible()")
.unwrap());
// A move away arms Apply; dragging back onto the committed value disarms it.
s.run("OptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:SetValue(0.79)")
.unwrap();
assert!(s
.eval::<bool>("return OptionsFrameApplyButton:IsVisible()")
.unwrap());
s.run("OptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:SetValue(0.8)")
.unwrap();
assert!(!s
.eval::<bool>("return OptionsFrameApplyButton:IsVisible()")
.unwrap());
assert!(
s.take_cvar_changes().is_empty(),
"arming and disarming never touched the CVar"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults on the Graphics page: both CVars back to their registered defaults (uiScale 0.9,
/// farclip 777), rows following, and ONLY the moved values queue.
/// Pending edits are PANEL-wide like era's modified table: they survive a category switch
/// (the row redisplays the pending value on return, era's GetValue-returns-pending) and die
/// only when the window hides — the reopened window reads the committed truth.
#[test]
fn defaults_resets_the_graphics_page_to_registered_defaults() {
let mut s = audio_harness();
s.set_cvar_host("uiScale", "0.8");
s.set_cvar_host("farclip", "297");
let mut s = harness_on(s);
fn a_pending_ui_scale_survives_the_page_switch_and_dies_on_hide() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
s.run("OptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:SetValue(0.7)")
.unwrap();
let _ = s.take_cvar_changes();
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
let changes = s.take_cvar_changes();
assert!(
changes.contains(&("uiScale".to_string(), "0.9".to_string())),
"{changes:?}"
// Off to Audio: the Apply button stays (the pending edit is not page-scoped)…
s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap();
assert!(s
.eval::<bool>("return OptionsFrameApplyButton:IsVisible()")
.unwrap());
// …and back: the slider shows the PENDING value, not the committed one.
s.run("OptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyGraphicsRowUiScaleControlValue:GetText()"
)
.unwrap(),
"70%"
);
assert!(
changes.contains(&("farclip".to_string(), "777".to_string())),
"{changes:?}"
);
assert_eq!(changes.len(), 2, "only the moved values queue: {changes:?}");
// Hide discards (the era confirm dialog is cut): the reopened window reads the truth.
s.run("HideUIPanel(OptionsFrame)").unwrap();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyGraphicsRowUiScaleControlValue:GetText()"
@@ -566,12 +582,164 @@ fn defaults_resets_the_graphics_page_to_registered_defaults() {
.unwrap(),
"90%"
);
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyGraphicsRowFarclipControlValue:GetText()"
)
.unwrap(),
"777"
assert!(!s
.eval::<bool>("return OptionsFrameApplyButton:IsVisible()")
.unwrap());
assert!(
s.take_cvar_changes().is_empty(),
"the whole pending lifecycle never wrote the CVar"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults on the Graphics page: uiScale back to its registered default (0.9), the row
/// following, ONLY the moved value queuing — and a pending edit dies with it (the default
/// write supersedes what Apply would have committed).
#[test]
fn defaults_resets_the_graphics_page_to_registered_defaults() {
let mut s = audio_harness();
s.set_cvar_host("uiScale", "0.8");
let mut s = harness_on(s);
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
// Stage a pending edit too — Defaults must kill it, not commit it.
s.run("OptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:SetValue(0.7)")
.unwrap();
let _ = s.take_cvar_changes();
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
let changes = s.take_cvar_changes();
assert_eq!(
changes,
vec![("uiScale".to_string(), "0.9".to_string())],
"only the default write queues — never the dead pending"
);
assert_eq!(
s.eval::<String>(
"return OptionsFrameContainerBodyGraphicsRowUiScaleControlValue:GetText()"
)
.unwrap(),
"90%"
);
assert!(!s
.eval::<bool>("return OptionsFrameApplyButton:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Controls is the DEFAULT page and has rows since 0961: opening the window lands on it with
/// Defaults armed, and the rows read the table — Sticky Targeting INVERTED (checked when
/// `deselectOnClick` is "0", the 1.12 interface panel's own arm), the plain flags direct.
#[test]
fn the_controls_page_reads_flags_with_the_sticky_inversion() {
let mut s = audio_harness();
s.set_cvar_host("deselectOnClick", "0");
s.set_cvar_host("autoLootDefault", "1");
let s = harness_on(s);
s.run("ShowUIPanel(OptionsFrame)").unwrap();
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyControls:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled()")
.unwrap());
assert!(
s.eval::<bool>("return OptionsFrameContainerBodyControlsRowStickyTargetCheck:GetChecked()")
.unwrap(),
"deselectOnClick '0' reads as Sticky Targeting CHECKED"
);
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyControlsRowAutoLootCheck:GetChecked()")
.unwrap());
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyControlsRowInvertMouseCheck:GetChecked()")
.unwrap());
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyControlsRowStickyTargetLabel:GetText()")
.unwrap(),
"Sticky Targeting"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The Controls checkboxes write their flags on the INTERFACE panel's kit mapping (checked →
/// CheckBoxOn — OptionsFrame.lua's PlayClickSound, NOT the Audio page's inverted quirk), and
/// Sticky Targeting writes the CVar inverted both ways.
#[test]
fn the_controls_checkboxes_write_flags_with_the_interface_panel_kit() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
assert!(
s.take_cvar_changes().is_empty(),
"reading the table on open must not write it back"
);
let _ = s.take_sounds();
// Invert Mouse on: flag "1", and the just-CHECKED box plays CheckBoxOn (normal mapping).
s.run("OptionsFrameContainerBodyControlsRowInvertMouseCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("mouseInvertPitch".to_string(), "1".to_string())]
);
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into())));
// Sticky Targeting on: the write INVERTS — checking it writes deselectOnClick "0".
s.run("OptionsFrameContainerBodyControlsRowStickyTargetCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("deselectOnClick".to_string(), "0".to_string())]
);
// …and off again: back to "1", the just-UNchecked box on the CheckBoxOff kit.
s.run("OptionsFrameContainerBodyControlsRowStickyTargetCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("deselectOnClick".to_string(), "1".to_string())]
);
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOff".into())));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults on the Controls page: the moved flags come back (deselectOnClick "1",
/// autoLootDefault "0"), the rows follow, and only the moved values queue.
#[test]
fn defaults_resets_the_controls_page_to_registered_defaults() {
let mut s = audio_harness();
s.set_cvar_host("deselectOnClick", "0");
s.set_cvar_host("autoLootDefault", "1");
let mut s = harness_on(s);
s.run("ShowUIPanel(OptionsFrame)").unwrap();
let _ = s.take_cvar_changes();
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
let changes = s.take_cvar_changes();
assert!(
changes.contains(&("deselectOnClick".to_string(), "1".to_string())),
"{changes:?}"
);
assert!(
changes.contains(&("autoLootDefault".to_string(), "0".to_string())),
"{changes:?}"
);
assert_eq!(changes.len(), 2, "only the moved values queue: {changes:?}");
assert!(
!s.eval::<bool>(
"return OptionsFrameContainerBodyControlsRowStickyTargetCheck:GetChecked()"
)
.unwrap(),
"deselectOnClick back at '1' reads as Sticky Targeting unchecked"
);
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyControlsRowAutoLootCheck:GetChecked()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}