- Area triggers exist now, so portals work: every teleport pad, dungeon entrance and enter-a-place quest volume reports the reference's own way — one exit latch, first containing volume on the map wins, a sphere or a yawed box. Verified live: Rut'theran to Darnassus, the Deadmines worldport, and a level-gated entrance refusing with the server's own "You must be at least level 10 to enter." - GameObjects animate. Every animated object arms its idle the moment it goes live (doors and chests used to flash bind pose for a frame), a missing rest pose is remapped the way the reference remaps it instead of playing nothing — the Ahn'Qiraj gate's root tangle stood frozen for want of a Closed clip — and the state machine covers the verified twenty of thirty-one object types instead of three: fourteen hundred spawns' worth of traps, goobers, books and questgivers now change pose off the wire. - The flickering tent is still. Cloth you can see from both sides is authored as two reversed copies of the same sheet, and the real client backface-culls each copy from its wrong side so exactly one covers any pixel; we drew every building batch two-sided, which left the winner to floating-point noise, flickering while the camera crept. WMO materials now honor their cull flag. - A sweep of long-standing visual bugs: effects respect the far-clip wall — a ferry's deck lanterns were drawing from 7,000 yards away; the night-elf eye glow goes out in first person instead of leaving two orbs burning in mid-air; the goblins' stray white hair tuft is gone (a duplicated table key resolved last-wins where the client scans first-wins); lava and slime finally have their own submerged view — the dense fiery orange and dense green of the real thing, off two fixed lighting rows nothing in the zone data points at; and the far-horizon band overlaps the detailed terrain instead of leaving a strip of sky at lowered view distance. - Two caught at the keyboard: the backpack icon greys instead of vanishing while the escape menu is up, and the world map is a real panel now — it can no longer open over the menu, and ESC closes it first like everything else. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
220 lines
8.6 KiB
Rust
220 lines
8.6 KiB
Rust
//! Which faces of a WMO are **authored twice** — two triangles on the same three positions:
|
|
//! `cargo run -p benilla-formats --example wmo_doubled -- <wmo-path-or-substring>`
|
|
//! e.g. `wmo_doubled orctower`.
|
|
//!
|
|
//! The falsifier `wmo_coplanar` structurally cannot be. That tool skips same-batch face pairs that
|
|
//! share a vertex ("two halves of a quad") — but a thin sheet whose two *sides* are authored by
|
|
//! reusing the same vertices with **reversed winding** shares all three, so a doubled canvas awning
|
|
//! is invisible to it by construction (it reported the B38 tent's nearest partner at 1.91 yd while
|
|
//! the tent's own two sides sat at 0.000). This tool asks the opposite question: which triangles
|
|
//! occupy the SAME three positions?
|
|
//!
|
|
//! Doubled-with-opposite-winding is not a defect — it is how 1.12 authors visible-from-both-sides
|
|
//! cloth when the material is single-sided: the real client backface-culls each copy from the wrong
|
|
//! side, so exactly one covers any pixel. It *becomes* a defect the moment the renderer draws WMO
|
|
//! batches two-sided (ours does, `group.rs` "keep two-sided for now"): both copies then rasterize at
|
|
//! depths equal to the ulp, and the winner per pixel is floating-point noise — latched while the
|
|
//! camera is bit-still, flickering while anything creeps. So each doubled pair is printed with its
|
|
//! batches' MOMT flags: `0x04` (UNCULLED) set means the file *asked* for two-sided and the double
|
|
//! draw is authored intent; clear means the renderer's shortcut is manufacturing a z-tie the file
|
|
//! never contained.
|
|
//!
|
|
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
|
|
|
|
use std::collections::HashMap;
|
|
|
|
use benilla_wmo::{parse_wmo, ParsedWmo};
|
|
|
|
/// Positions are matched to a tenth of a millimetre — far under authoring precision, far over f32
|
|
/// noise at building scale.
|
|
const QUANT: f32 = 10_000.0;
|
|
|
|
fn key(p: [f32; 3]) -> [i64; 3] {
|
|
p.map(|c| (c * QUANT).round() as i64)
|
|
}
|
|
|
|
/// A triangle's identity (order-free) plus the data needed to classify its partner.
|
|
struct Tri {
|
|
batch: usize,
|
|
/// Geometric normal in original winding order — two copies with `dot < 0` are opposite-wound.
|
|
normal: [f32; 3],
|
|
/// Mean authored (MONR) normal, to name which side of the sheet this copy is.
|
|
authored: [f32; 3],
|
|
}
|
|
|
|
fn main() -> anyhow::Result<()> {
|
|
let pat = std::env::args()
|
|
.nth(1)
|
|
.ok_or_else(|| anyhow::anyhow!("usage: wmo_doubled <wmo-path-or-substring>"))?;
|
|
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
|
|
let mut chain = benilla_formats::open_chain(&data)?;
|
|
let path = if pat.to_lowercase().ends_with(".wmo") {
|
|
pat.clone()
|
|
} else {
|
|
let lower = pat.to_lowercase();
|
|
chain
|
|
.list()?
|
|
.into_iter()
|
|
.map(|e| e.name)
|
|
.find(|n| {
|
|
let l = n.to_lowercase();
|
|
l.ends_with(".wmo") && l.contains(&lower) && !l.contains("_00")
|
|
})
|
|
.ok_or_else(|| anyhow::anyhow!("no root .wmo matching {pat:?}"))?
|
|
};
|
|
println!("{path}\n");
|
|
|
|
// The root's MOMT table, raw — the submeshes don't carry material flags, and the flags decide
|
|
// whether a doubled pair is authored intent (0x04 UNCULLED) or a renderer-manufactured tie.
|
|
let root_bytes = chain.read_file(&path.to_ascii_lowercase())?;
|
|
let (materials, textures, tex_map) = match parse_wmo(&mut std::io::Cursor::new(&root_bytes))? {
|
|
ParsedWmo::Root(r) => (r.materials, r.textures, r.texture_offset_index_map),
|
|
ParsedWmo::Group(_) => anyhow::bail!("{path} is a group file, want the root"),
|
|
};
|
|
println!("=== MOMT ({} materials) ===", materials.len());
|
|
for (i, m) in materials.iter().enumerate() {
|
|
let tex = textures
|
|
.get(m.get_texture1_index(&tex_map) as usize)
|
|
.map(String::as_str)
|
|
.unwrap_or("(none)");
|
|
println!(
|
|
"[{i:3}] flags {:#06x}{} blend {} {tex}",
|
|
m.flags,
|
|
if m.flags & 0x04 != 0 { " UNCULLED" } else { "" },
|
|
m.blend_mode,
|
|
);
|
|
}
|
|
|
|
let subs = benilla_formats::load_wmo(&mut chain, &path)?;
|
|
|
|
// One global bucket per geometric triangle, across every batch: any bucket holding two entries
|
|
// is a doubled face, wherever its copies live.
|
|
let mut buckets: HashMap<[[i64; 3]; 3], Vec<Tri>> = HashMap::new();
|
|
for (bi, s) in subs.iter().enumerate() {
|
|
for t in s.indices.chunks_exact(3) {
|
|
let p: Vec<[f32; 3]> = t.iter().map(|&i| s.positions[i as usize]).collect();
|
|
let (u, v) = (sub3(p[1], p[0]), sub3(p[2], p[0]));
|
|
let n = cross(u, v);
|
|
if len(n) < 1e-9 {
|
|
continue; // degenerate: no facing to double
|
|
}
|
|
let mut authored = [0.0f32; 3];
|
|
for &i in t {
|
|
let a = s.normals.get(i as usize).copied().unwrap_or([0.0; 3]);
|
|
for c in 0..3 {
|
|
authored[c] += a[c];
|
|
}
|
|
}
|
|
let mut k = [key(p[0]), key(p[1]), key(p[2])];
|
|
k.sort_unstable();
|
|
buckets.entry(k).or_default().push(Tri {
|
|
batch: bi,
|
|
normal: n,
|
|
authored,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Doubled-face census per batch pair, split by winding — opposite is the two-sided-sheet
|
|
// authoring; same-winding true duplicates would be a different (and stranger) finding.
|
|
/// Per batch pair: doubled-face count + each side's summed authored normal.
|
|
type OppositeCensus = HashMap<(usize, usize), (usize, [f32; 3], [f32; 3])>;
|
|
let mut opposite: OppositeCensus = HashMap::new();
|
|
let mut same: HashMap<(usize, usize), usize> = HashMap::new();
|
|
for tris in buckets.values() {
|
|
for i in 0..tris.len() {
|
|
for j in (i + 1)..tris.len() {
|
|
let (a, b) = (&tris[i], &tris[j]);
|
|
let pair = (a.batch.min(b.batch), a.batch.max(b.batch));
|
|
if dot(a.normal, b.normal) < 0.0 {
|
|
let e = opposite.entry(pair).or_insert((0, [0.0; 3], [0.0; 3]));
|
|
e.0 += 1;
|
|
// Accumulate each side's authored normal by batch order, so the report can say
|
|
// "batch A faces up, batch B faces down" rather than just "they differ".
|
|
let (first, second) = if a.batch <= b.batch { (a, b) } else { (b, a) };
|
|
for c in 0..3 {
|
|
e.1[c] += first.authored[c];
|
|
e.2[c] += second.authored[c];
|
|
}
|
|
} else {
|
|
*same.entry(pair).or_default() += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let describe = |bi: usize| {
|
|
let s = &subs[bi];
|
|
format!(
|
|
"[{bi:3}] bias {:3} {:?} {}",
|
|
bi + 1,
|
|
s.blend,
|
|
s.texture.as_deref().unwrap_or("(none)"),
|
|
)
|
|
};
|
|
println!("\n=== doubled faces, OPPOSITE winding (the two-sided-sheet authoring) ===");
|
|
let mut pairs: Vec<_> = opposite.iter().collect();
|
|
pairs.sort_by_key(|(k, _)| **k);
|
|
if pairs.is_empty() {
|
|
println!("(none)");
|
|
}
|
|
for (&(a, b), &(count, na, nb)) in pairs {
|
|
println!(
|
|
"{count:5} tri {} <-> {}",
|
|
describe(a),
|
|
if a == b {
|
|
"ITSELF".to_string()
|
|
} else {
|
|
describe(b)
|
|
}
|
|
);
|
|
println!(
|
|
" side A authored normal ~[{:+.2},{:+.2},{:+.2}] side B ~[{:+.2},{:+.2},{:+.2}]",
|
|
na[0] / count as f32 / 3.0,
|
|
na[1] / count as f32 / 3.0,
|
|
na[2] / count as f32 / 3.0,
|
|
nb[0] / count as f32 / 3.0,
|
|
nb[1] / count as f32 / 3.0,
|
|
nb[2] / count as f32 / 3.0,
|
|
);
|
|
}
|
|
|
|
println!(
|
|
"\n=== doubled faces, SAME winding (true duplicates — would double-draw even culled) ==="
|
|
);
|
|
if same.is_empty() {
|
|
println!("(none)");
|
|
}
|
|
let mut pairs: Vec<_> = same.iter().collect();
|
|
pairs.sort_by_key(|(k, _)| **k);
|
|
for (&(a, b), &count) in pairs {
|
|
println!(
|
|
"{count:5} tri {} <-> {}",
|
|
describe(a),
|
|
if a == b {
|
|
"ITSELF".to_string()
|
|
} else {
|
|
describe(b)
|
|
}
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn sub3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
|
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
|
}
|
|
fn cross(u: [f32; 3], v: [f32; 3]) -> [f32; 3] {
|
|
[
|
|
u[1] * v[2] - u[2] * v[1],
|
|
u[2] * v[0] - u[0] * v[2],
|
|
u[0] * v[1] - u[1] * v[0],
|
|
]
|
|
}
|
|
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
|
|
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
}
|
|
fn len(a: [f32; 3]) -> f32 {
|
|
dot(a, a).sqrt()
|
|
}
|