dark doorways light up, and the Audio and Graphics pages land

- a building's transition corridors carry the reference's bright-doorway fade: the black tunnel between sunlit outside and a lit interior is gone
- the Options window gets its first real pages: seven 1.12 sound rows on Audio and the two 1.12 Video Options sliders on Graphics, all live CVars
- terrain culls like the real client: the ground is one-sided, so from underneath you see through it exactly as 1.12.1 does
- the beam system's data plane lands: 48 live chain, drain and funnel beams read off the real patch chain, with a byte-exact wire golden — the renderer follows

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