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:
@@ -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
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+112
-493
@@ -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");
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
®ion,
|
||||
"SetAtlas",
|
||||
(atlas.to_string(), t.attr_bool("useAtlasSize")),
|
||||
dbg,
|
||||
);
|
||||
}
|
||||
if let Some(mode) = t.attr("alphaMode") {
|
||||
this.call_region(®ion, "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(
|
||||
®ion,
|
||||
"SetAtlas",
|
||||
(atlas.to_string(), tt.attr_bool("useAtlasSize")),
|
||||
dbg,
|
||||
);
|
||||
}
|
||||
if let Some(mode) = tt.attr("alphaMode") {
|
||||
self.call_region(®ion, "SetBlendMode", mode.to_string(), dbg);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user