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:
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, °enerate, &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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>)>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user