diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index 6349ffcd..b6d241a1 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -460,6 +460,10 @@ impl AssetLoader for M2ModelLoader { // loader-idle seed below resolves through it. let playable_animation_lookup = parse_m2_playable_animation_lookup(&bytes).unwrap_or_default(); + // The model's own "do I author id X" table (the reference's `0x711960`) — the + // GameObject arm's missing-sequence remap branches on it (`ModelAnimations::owns`). + let animation_lookup = + benilla_formats::parse_m2_animation_lookup(&bytes).unwrap_or_default(); let mut clips = Vec::new(); // The loader-idle seed (decision 0637, wow-re `gameobject-anim-arm.md` §1 — the // corrected `0x71019b` read): the loader arms **animation id 0 ("Stand")** resolved @@ -557,6 +561,7 @@ impl AssetLoader for M2ModelLoader { graph, clips, playable_animation_lookup, + animation_lookup, hand_close, global_bones, first_seq, diff --git a/crates/benilla-assets/src/model/anims.rs b/crates/benilla-assets/src/model/anims.rs index 5ceeef49..81cd2899 100644 --- a/crates/benilla-assets/src/model/anims.rs +++ b/crates/benilla-assets/src/model/anims.rs @@ -96,6 +96,12 @@ pub struct ModelAnimations { /// `AnimationData.dbc` id `i`. Empty for a model whose header carries no table (a boneless /// doodad-shaped cube fallback, a malformed M2) — [`Self::resolve`] then degrades to identity. pub playable_animation_lookup: Vec, + /// The model's **AnimationLookup** (M2 header `+0x24`/`+0x28`): `AnimationData.dbc` id → the + /// model's first sequence slot for it, `0xffff` where it authors none. Kept beside the playable + /// table because it is a *different question* — "does this model own id X at all", the + /// reference's `0x711960` — and the GameObject arm's missing-sequence remap branches on it four + /// times (wow-re `gameobject-anim-arm.md` §2c). See [`Self::owns`]. + pub animation_lookup: Vec, /// The model's **global-sequence bone channels** baked to Bevy space (the eye-blink eyelid scale and /// kin): free-clock loops the runtime samples independently of the playing animation and composes /// over the sequence pose (see [`super::GlobalBone`]). Empty for a model with no global-sequence @@ -139,6 +145,17 @@ impl ModelAnimations { self.clips.iter().find(|c| c.anim_id == anim_id) } + /// **Does the model author `anim_id`?** — the reference's `0x711960`: a bounds-checked read of + /// [`Self::animation_lookup`] against the `0xffff` sentinel. Deliberately NOT `find(..).is_some()`: + /// `clips` drops sequences we can't build (zero-duration ones among them), so a clip scan can + /// answer "no" where the reference answers "yes" and takes a different branch. Use this to decide + /// what the reference would *request*; use [`Self::find`] to get something we can actually play. + pub fn owns(&self, anim_id: u16) -> bool { + self.animation_lookup + .get(anim_id as usize) + .is_some_and(|&slot| slot != 0xffff) + } + /// The clip a free/effect model instance runs: the caller's `preferred` id when the model has /// it (the thrown-weapon missile asks for InFlight), else the file-order-first clip — the /// existing default. One selector so the rig ([`crate::…`]'s `arm_effect_rig`) and its ribbon @@ -265,6 +282,7 @@ mod tests { graph: Handle::default(), clips: has_clips.iter().map(|&id| test_clip(id)).collect(), playable_animation_lookup: table, + animation_lookup: Vec::new(), hand_close: [None, None], global_bones: Vec::new(), first_seq: None, diff --git a/crates/benilla-formats/examples/wmo_doubled.rs b/crates/benilla-formats/examples/wmo_doubled.rs new file mode 100644 index 00000000..a6300683 --- /dev/null +++ b/crates/benilla-formats/examples/wmo_doubled.rs @@ -0,0 +1,219 @@ +//! Which faces of a WMO are **authored twice** — two triangles on the same three positions: +//! `cargo run -p benilla-formats --example wmo_doubled -- ` +//! 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 "))?; + 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> = 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() +} diff --git a/crates/benilla-formats/src/area_trigger.rs b/crates/benilla-formats/src/area_trigger.rs new file mode 100644 index 00000000..cc7fb59a --- /dev/null +++ b/crates/benilla-formats/src/area_trigger.rs @@ -0,0 +1,280 @@ +//! `AreaTrigger.dbc` — the world's invisible trip-wires, and the containment law that fires them. +//! +//! A trigger is a volume the *client* watches: when the player walks into one, the client sends +//! `CMSG_AREATRIGGER` naming its id and the **server** decides what that means — a teleport (every +//! instance entrance, the Darnassus/Rut'theran portals), a quest's "explore here" objective, the +//! inn's rested-XP state, a battleground's entrance list. The client owns only the geometry; it +//! never knows what any trigger does. +//! +//! Layout — 10 × 4-byte columns (record stride `0x28`, which is what the reference's own scan +//! strides by): `ID(0), MapID(1), X(2), Y(3), Z(4), Radius(5), BoxLength(6), BoxWidth(7), +//! BoxHeight(8), BoxYaw(9)`. Positions are WoW world space (the space `bevy_to_wow` produces), +//! yaw in radians. +//! +//! The containment law is [`AreaTriggerRow::contains`] — read from the reference's `0x5e22d0` +//! (wow-5875-re `object-layer/scratch/w2b1-decomp.c`), which is the only place this math lives. + +use std::collections::HashMap; + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::chain::Chain; +use crate::dbc::{f32_at, parse, u32_at}; + +const AREA_TRIGGER: &str = "DBFilesClient\\AreaTrigger.dbc"; + +/// One `AreaTrigger.dbc` row: a sphere (`radius != 0`) or an oriented box (`radius == 0`) sitting +/// on one map, in WoW world space. +#[derive(Clone, Copy, Debug)] +pub struct AreaTriggerRow { + /// The id that goes on the wire in `CMSG_AREATRIGGER`. + pub id: u32, + /// `Map.dbc` id the volume lives on. + pub map_id: u32, + /// Centre, WoW world space. + pub position: [f32; 3], + /// Sphere radius (yd). `0` ⇒ this row is a box; see [`Self::contains`]. + pub radius: f32, + /// **Full** box extents along the box's own local X/Y/Z (yd) — halved by the containment test, + /// which is how the reference reads them. + pub box_size: [f32; 3], + /// The box's yaw about world Z (radians, CCW from +X), i.e. where its local +X points. + pub box_yaw: f32, +} + +impl AreaTriggerRow { + /// Is `p` (WoW world space) inside this volume? + /// + /// **VERIFIED** against the reference's containment predicate `0x5e22d0` (wow-5875-re + /// `object-layer/scratch/w2b1-decomp.c`; the ledger classes it ORCHESTRATION over gx's matrix + /// ops, and the comparison it owns is what this reproduces): + /// + /// - **`radius != 0` ⇒ sphere**, tested in **3D** and **inclusively**: the reference computes + /// `Σ(centre − p)²` and returns "inside" on `radius² >= dist²`. + /// - **`radius == 0` ⇒ oriented box**: the point is carried into the box's own frame (translate + /// to the centre, rotate by **−`box_yaw`** about Z) and compared against ±half of each + /// `box_size` component, **strictly** on all six faces (`-h < l < h`). The reference does the + /// transform through gx (`0x7bdc40` load / `0x7bdd60` rotate-about-Z / `0x7bd700` apply) and + /// owns only the comparison; vmangos's `IsPointInAreaTriggerZone` reaches the same law from + /// the other side (it rotates the player by `2π − box_orientation`, which is this rotation). + /// + /// The server re-runs the same test with **5 yd of slop** before acting + /// (`MiscHandler.cpp:641`), so a marginally tight answer here still opens the door; a + /// marginally *loose* one is simply ignored. + pub fn contains(&self, p: [f32; 3]) -> bool { + if self.radius != 0.0 { + let d = [ + self.position[0] - p[0], + self.position[1] - p[1], + self.position[2] - p[2], + ]; + let dist_sq = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + return self.radius * self.radius >= dist_sq; + } + + // World → box-local: the box's local +X points along `box_yaw`, so the inverse rotation is + // by −yaw about Z. Z is unrotated (the volume never pitches or rolls). + let (sin, cos) = (-self.box_yaw).sin_cos(); + let dx = p[0] - self.position[0]; + let dy = p[1] - self.position[1]; + let local = [ + dx * cos - dy * sin, + dx * sin + dy * cos, + p[2] - self.position[2], + ]; + (0..3).all(|i| { + let half = self.box_size[i] * 0.5; + local[i] > -half && local[i] < half + }) + } +} + +/// Every `AreaTrigger.dbc` row, bucketed by map — the per-map window the reference narrows to +/// before each check (`0x5e2080` walks the map-sorted table for a `[first, end)` index range; +/// bucketing answers the same question without depending on the file staying sorted). +/// +/// **File order is preserved inside each bucket**, because the check takes the *first* containing +/// row and volumes do overlap (an instance entrance sits inside its building's larger trigger). +pub struct AreaTriggerCatalog { + by_map: HashMap>, + len: usize, +} + +impl AreaTriggerCatalog { + /// The triggers on `map_id`, in file order (empty for a map with none). + pub fn on_map(&self, map_id: u32) -> &[AreaTriggerRow] { + self.by_map.get(&map_id).map_or(&[], |v| v.as_slice()) + } + + /// The **first** trigger on `map_id` containing `p` — the reference's own pick + /// (`0x5e2110` breaks its scan on the first hit). + pub fn first_containing(&self, map_id: u32, p: [f32; 3]) -> Option<&AreaTriggerRow> { + self.on_map(map_id).iter().find(|t| t.contains(p)) + } + + /// A row by id (linear over its map's bucket is not available here — ids are unique table-wide, + /// so this walks every bucket; used by tests and diagnostics, never per frame). + pub fn get(&self, id: u32) -> Option<&AreaTriggerRow> { + self.by_map.values().flatten().find(|t| t.id == id) + } + + pub fn len(&self) -> usize { + self.len + } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } +} + +/// 10 u32/f32-wide columns (see module doc). +pub fn area_trigger_schema() -> Schema { + let mut s = Schema::new("AreaTrigger"); + s.add_field(SchemaField::new("ID", FieldType::UInt32)); + s.add_field(SchemaField::new("MapID", FieldType::UInt32)); + s.add_field(SchemaField::new("X", FieldType::Float32)); + s.add_field(SchemaField::new("Y", FieldType::Float32)); + s.add_field(SchemaField::new("Z", FieldType::Float32)); + s.add_field(SchemaField::new("Radius", FieldType::Float32)); + s.add_field(SchemaField::new("BoxLength", FieldType::Float32)); + s.add_field(SchemaField::new("BoxWidth", FieldType::Float32)); + s.add_field(SchemaField::new("BoxHeight", FieldType::Float32)); + s.add_field(SchemaField::new("BoxYaw", FieldType::Float32)); + s.set_key_field("ID"); + s +} + +/// Read `AreaTrigger.dbc` off the patch chain into an [`AreaTriggerCatalog`]. +pub fn load_area_trigger_catalog(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(AREA_TRIGGER) + .context("reading AreaTrigger.dbc")?; + let rs = parse(&bytes, area_trigger_schema(), "AreaTrigger")?; + let mut by_map: HashMap> = HashMap::new(); + let mut len = 0; + for r in rs.records() { + let (Some(id), Some(map_id)) = (u32_at(r, 0), u32_at(r, 1)) else { + continue; + }; + let f = |i| f32_at(r, i).unwrap_or(0.0); + by_map.entry(map_id).or_default().push(AreaTriggerRow { + id, + map_id, + position: [f(2), f(3), f(4)], + radius: f(5), + box_size: [f(6), f(7), f(8)], + box_yaw: f(9), + }); + len += 1; + } + Ok(AreaTriggerCatalog { by_map, len }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sphere(radius: f32) -> AreaTriggerRow { + AreaTriggerRow { + id: 1, + map_id: 0, + position: [10.0, 20.0, 30.0], + radius, + box_size: [0.0; 3], + box_yaw: 0.0, + } + } + + /// The sphere leg: 3D, and inclusive exactly on the surface (the reference's `radius² >= d²`). + #[test] + fn sphere_is_3d_and_inclusive() { + let t = sphere(5.0); + assert!(t.contains([10.0, 20.0, 30.0])); + assert!( + t.contains([15.0, 20.0, 30.0]), + "exactly on the surface is in" + ); + assert!(!t.contains([15.1, 20.0, 30.0])); + // Z counts: straight up out of the sphere is out, which a 2D test would miss. + assert!(!t.contains([10.0, 20.0, 36.0])); + assert!(t.contains([10.0, 20.0, 34.0])); + } + + /// The box leg: half-extents, strict faces, and the yaw actually rotating the volume. + #[test] + fn box_is_half_extents_strict_and_yawed() { + let axis_aligned = AreaTriggerRow { + id: 2, + map_id: 0, + position: [0.0, 0.0, 0.0], + radius: 0.0, + box_size: [10.0, 4.0, 2.0], + box_yaw: 0.0, + }; + assert!(axis_aligned.contains([4.9, 1.9, 0.9])); + // Half, not full: 6 yd along a 10-yd box is outside. + assert!(!axis_aligned.contains([6.0, 0.0, 0.0])); + // Strict on the face itself. + assert!(!axis_aligned.contains([5.0, 0.0, 0.0])); + assert!(!axis_aligned.contains([0.0, 0.0, 1.0])); + + // Rotate the same box a quarter turn: the long axis now runs along world +Y, so a point + // that was inside along X is outside, and its mirror along Y is inside. + let turned = AreaTriggerRow { + box_yaw: std::f32::consts::FRAC_PI_2, + ..axis_aligned + }; + assert!(!turned.contains([4.0, 0.0, 0.0])); + assert!(turned.contains([0.0, 4.0, 0.0])); + } + + /// The real 5875 table: the Darnassus portal pair the reports name (B70), a box row and a + /// sphere row both present, and the per-map bucketing. Skips without client data. + #[test] + fn real_area_triggers_load_and_contain_their_own_centres() { + 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 cat = load_area_trigger_catalog(&mut chain).expect("load AreaTrigger"); + assert_eq!(cat.len(), 432, "5875 ships 432 area triggers"); + + // The two the Darnassus report rides on (B70), both on Kalimdor, both 10-yd spheres. They + // pin the coordinate columns from *outside* the client: vmangos's `areatrigger_teleport` + // sends 527 ("Darnassus - Exit") to (8785.79, 966.98, 30.20) — which is trigger **542**'s + // own position here, and vice versa. Two independent sources, agreeing to a couple of yards, + // on a pair of doorways that teleport to each other. + let exit = *cat.get(527).expect("trigger 527 (Darnassus - Exit)"); + let entrance = *cat.get(542).expect("trigger 542 (Darnassus - Entrance)"); + assert_eq!((exit.map_id, entrance.map_id), (1, 1)); + assert!(exit.contains([9947.0, 2630.0, 1318.0]), "{exit:?}"); + assert!(entrance.contains([8799.0, 970.0, 30.0]), "{entrance:?}"); + // …and 25 yd away is outside a 10-yd sphere. + assert!(!entrance.contains([8824.0, 970.0, 30.0])); + + // Every volume contains its own centre — the one invariant that catches a column shift + // (a size read as a coordinate, or a radius read from the wrong slot, breaks it). + for t in cat.on_map(1) { + assert!( + t.contains(t.position), + "trigger {} does not contain its own centre: {t:?}", + t.id + ); + } + + // Both shapes are represented in the shipped table. + // Both shapes ship: 80 boxes, the rest spheres. + let boxes = cat.on_map(0).iter().filter(|t| t.radius == 0.0).count(); + assert!( + boxes > 0 && boxes < cat.on_map(0).len(), + "map 0 has both shapes" + ); + + // Buckets are per map, and the two continents carry most of the table. + assert!(cat.on_map(1).iter().all(|t| t.map_id == 1)); + assert_eq!((cat.on_map(0).len(), cat.on_map(1).len()), (133, 121)); + } +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index 281f9308..4e2c0be6 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -180,6 +180,17 @@ enum Command { /// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted. prefix: Option, }, + /// Sweep every model named by **GameObjectDisplayInfo.dbc** and resolve what the reference's + /// GameObject animation arm plays in each reachable `GAMEOBJECT_STATE` × `GAMEOBJECT_ANIMPROGRESS` + /// substate (wow-re `gameobject-anim-arm.md` §2b/§2c: the substate table, the `0x8607e4` LUT, the + /// four-way missing-sequence remap) — beside the generic loader seed the same model gets at build + /// (§1, animation id 0 through `playableAnimationLookup`). The population instrument for "which + /// GameObjects can the wire state actually be SEEN on": a model whose every substate lands on the + /// seed's own sequence is state-blind, so skipping the arm on its GO type costs nothing, while a + /// STATE-SENSITIVE model renders in the wrong pose the moment its type is left off the machine. + /// Also counts the models whose pose depends on the §2c remap (benilla plays nothing there today, + /// i.e. bind pose) and the ones reaching a rate-0 freeze leg. + Goanimscan, /// Sweep every `.m2` (optionally under a path prefix) and census the models whose batch /// visibility is PER SEQUENCE — geometry the reference draws in one animation and skips in /// another (the verified `A <= 0` alpha cull). The population instrument for "a single-sequence @@ -254,6 +265,36 @@ enum Command { /// Game minute-of-day (0..1440; 720 = noon). minute: u32, }, + /// Dump every `LightIntBand`/`LightFloatBand` row of an explicitly named `LightParams` id — the + /// instrument for rows **no position can reach**, because nothing in `Light.dbc` references them: + /// magma submersion reads the fixed global row 7 and slime row 6 (VERIFIED `0x6d2371`), not the + /// zone's underwater slot. + Lightparam { + /// `LightParams.dbc` id (1-based). + id: u32, + /// Game minute-of-day (0..1440; 720 = noon). + minute: u32, + }, + /// Dump the **per-weather-slot** light resolve at a world position: which `LightParams` id each + /// of the five `Light.dbc` slots names (clear / clear-underwater / storm / storm-underwater / + /// death), which of them are UNSET (and so fall back to clear), and the fog/ambient/diffuse the + /// blend resolves for each. `lightbands` only ever shows the clear slot, so an underwater or + /// ghost atmosphere that reads wrong could not be told from data that simply has no such record. + Lightslots { + /// Map id (0 = Eastern Kingdoms, 1 = Kalimdor). + map: u32, + /// World-space X (raw WoW yards; may be negative). + #[arg(allow_hyphen_values = true)] + x: f32, + /// World-space Y. + #[arg(allow_hyphen_values = true)] + y: f32, + /// World-space Z. + #[arg(allow_hyphen_values = true)] + z: f32, + /// Game minute-of-day (0..1440; 720 = noon). + minute: u32, + }, /// Bulk-scan placed doodads (MDDF) and WMO doodad-set-0 props (MODF → MODS/MODD) across a /// `(2·tile_radius+1)²` block of ADT tiles around a world position, and report how much of that /// content animates (see `m2anim`) — the doodad-animation perf/scope instrument. @@ -405,6 +446,7 @@ fn main() -> Result<()> { Command::Groundscan { prefix } => scan::groundscan(&mut chain, prefix.as_deref())?, Command::Geosetscan { prefix } => scan::geosetscan(&mut chain, prefix.as_deref())?, Command::Alphascan { prefix } => scan::alphascan(&mut chain, prefix.as_deref())?, + Command::Goanimscan => scan::goanimscan(&mut chain)?, Command::Bonescan { prefix } => scan::bonescan(&mut chain, prefix.as_deref())?, Command::Partcensus { prefix } => scan::partcensus(&mut chain, prefix.as_deref())?, Command::Partscan { mask, prefix } => { @@ -424,6 +466,20 @@ fn main() -> Result<()> { // `debug_bands` takes the client's half-minute clock (0..2880). catalog.debug_bands(map, [x, y, z], minute * 2); } + Command::Lightparam { id, minute } => { + let catalog = benilla_formats::LightCatalog::load(&mut chain)?; + catalog.debug_param(id, minute * 2); + } + Command::Lightslots { + map, + x, + y, + z, + minute, + } => { + let catalog = benilla_formats::LightCatalog::load(&mut chain)?; + catalog.debug_slots(map, [x, y, z], minute * 2); + } Command::Placescan { map, center_x, diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan.rs b/crates/benilla-formats/src/bin/benilla-extract/scan.rs index 5e2abbf4..0d646241 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan.rs @@ -1936,3 +1936,191 @@ pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { } Ok(()) } + +/// The GameObject animation arm's LUT (wow-re `gameobject-anim-arm.md` §2c, `.data 0x8607e4`): +/// internal **substate** → the `AnimationData.dbc` id the object layer arms. +const SUBSTATE_ANIM: [u16; 13] = [ + 145, // 0 Spawn — NO client path produces this substate (§2c census) + 147, // 1 Closed (rest) + 148, // 2 Open (motion) + 149, // 3 Opened (rest) + 146, // 4 Close (motion) + 150, // 5 Destroy (motion) + 151, // 6 Destroyed (rest) + 152, // 7 Rebuild (motion) + 153, 154, 155, 156, // 8..11 Custom0-3 — reachable only via SMSG_GAMEOBJECT_CUSTOM_ANIM + 157, // 12 Despawn +]; + +/// The six substates a `GAMEOBJECT_STATE` × `GAMEOBJECT_ANIMPROGRESS` pair can actually produce +/// (§2b). Substate 0 (Spawn) has no producer at all, and 8..12 come from other opcodes entirely. +const REACHABLE: [(usize, &str); 6] = [ + (1, "READY settled"), + (4, "READY mid "), + (3, "ACTIVE settled"), + (2, "ACTIVE mid "), + (6, "ALT settled"), + (5, "ALT mid "), +]; + +/// The §2c four-way remap: what the arm actually requests when the model doesn't author the +/// substate's LUT id. Returns `(id, rate0)` — `rate0` marks the two legs that freeze a *motion* +/// clip at frame 0 to stand in for a missing *rest* pose. +fn go_remap(m: &benilla_m2::M2Model, id: u16) -> (u16, bool) { + if m.owns_animation(id) { + return (id, false); + } + match id { + // Close missing: keep it (op4 resolves onward) if Open exists, else fall to Closed. + 146 => (if m.owns_animation(148) { 146 } else { 147 }, false), + // Closed missing: keep it if Close exists; else freeze Open at frame 0; else Stand. + 147 if m.owns_animation(146) => (147, false), + 147 if m.owns_animation(148) => (148, true), + 147 => (0, false), + // Open missing: keep it if Close exists; else Destroy if present; else Opened. + 148 => ( + if m.owns_animation(146) { + 148 + } else if m.owns_animation(150) { + 150 + } else { + 149 + }, + false, + ), + // Opened missing: keep it if Open exists; else freeze Close at frame 0; else Destroyed. + 149 if m.owns_animation(148) => (149, false), + 149 if m.owns_animation(146) => (146, true), + 149 => (151, false), + // Outside the door group there is no remap — the id goes to op4 as-is. + other => (other, false), + } +} + +/// op4's own id → played sequence resolve (`0x7121a0` via `0x711bf0`): the model's +/// `playableAnimationLookup` row (when the id is in range), then `animationLookup` to a file slot. +/// `None` when nothing playable comes out — the reference arms nothing and the pose simply stands. +fn go_resolve_slot(m: &benilla_m2::M2Model, id: u16) -> Option<(u16, u16)> { + let played = m + .playable_animation_lookup + .get(id as usize) + .map_or(id, |p| p.resolved_id); + let slot = *m.animation_lookup.get(played as usize)?; + (slot != 0xffff).then_some((played, slot)) +} + +/// The generic loader seed (§1, `0x71019b`): resolve id 0, and arm **id 0** when the model owns what +/// that resolves to — only the degenerate leg (owning nothing reachable) falls back to the raw +/// `animations[0]` dword. +fn go_loader_seed( + m: &benilla_m2::M2Model, + seqs: &[benilla_formats::ModelAnimation], +) -> Option<(u16, u16)> { + let resolved = m + .playable_animation_lookup + .first() + .map_or(0, |p| p.resolved_id); + if m.owns_animation(resolved) { + go_resolve_slot(m, 0) + } else { + // The degenerate leg: `animations[0]`'s low16 — the file-order-first sequence's own id. + go_resolve_slot(m, seqs.first()?.anim_id) + } +} + +/// Sweep every model named by **GameObjectDisplayInfo.dbc** and resolve, per model, what the +/// reference's GameObject animation arm plays in each reachable `GAMEOBJECT_STATE` × +/// `GAMEOBJECT_ANIMPROGRESS` substate — see the `Goanimscan` command doc. +pub fn goanimscan(chain: &mut Chain) -> Result<()> { + let catalog = + benilla_formats::load_gameobject_catalog(chain).context("GameObjectDisplayInfo.dbc")?; + // displayId → path, deduped to one entry per model (many displays share a model). + let mut models: BTreeMap> = BTreeMap::new(); + for (id, path) in catalog.iter() { + let key = model_key(path); + if key.ends_with(".m2") { + models.entry(key).or_default().push(id); + } + } + for ids in models.values_mut() { + ids.sort_unstable(); + } + let (mut parsed, mut no_seq, mut blind, mut sensitive, mut needs_remap, mut rate0) = + (0u32, 0u32, 0u32, 0u32, 0u32, 0u32); + for (path, displays) in &models { + let Ok(bytes) = chain.read_file(path) else { + continue; + }; + let Ok(fmt) = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes)) else { + continue; + }; + let m = fmt.model(); + parsed += 1; + let seqs = benilla_formats::parse_m2_animations(&bytes); + if seqs.is_empty() { + no_seq += 1; + continue; + } + let seed = go_loader_seed(m, &seqs); + let mut lines = Vec::new(); + let (mut differs, mut remapped, mut froze) = (false, false, false); + for (sub, label) in REACHABLE { + let lut = SUBSTATE_ANIM[sub]; + let (req, r0) = go_remap(m, lut); + let armed = go_resolve_slot(m, req); + remapped |= req != lut; + froze |= r0; + differs |= armed.map(|(_, s)| s) != seed.map(|(_, s)| s); + lines.push(format!( + " {label} sub{sub} lut {lut}{} -> {}{}", + if req == lut { + String::new() + } else { + format!(" (remap {req})") + }, + match armed { + Some((id, slot)) => format!("id {id} slot {slot}"), + None => "NOTHING".to_string(), + }, + if r0 { " [rate 0 — frozen]" } else { "" }, + )); + } + if differs { + sensitive += 1; + } else { + blind += 1; + } + remapped.then(|| needs_remap += 1); + froze.then(|| rate0 += 1); + // Only the state-SENSITIVE models are worth printing: on every other one the arm lands on + // the same sequence the loader seed already holds, so `GAMEOBJECT_STATE` is unobservable. + if differs { + println!("{path} ({} sequences, displays {displays:?})", seqs.len()); + println!( + " loader seed -> {}", + match seed { + Some((id, slot)) => format!("id {id} slot {slot}"), + None => "NOTHING".to_string(), + } + ); + for l in lines { + println!("{l}"); + } + } + } + println!( + "\n{} GameObjectDisplayInfo M2 models, {parsed} parsed, {no_seq} with no sequences", + models.len() + ); + println!( + " STATE-BLIND {blind} — every reachable substate lands on the loader seed's own \ + sequence, so GAMEOBJECT_STATE cannot be seen on this model at all" + ); + println!( + " STATE-SENSITIVE {sensitive} — at least one substate plays something else: exactly the \ + models a GO type that skips the arm renders in the wrong pose" + ); + println!(" needing the §2c remap on some substate: {needs_remap}"); + println!(" hitting a rate-0 freeze leg (a motion clip standing in for a missing rest pose): {rate0}"); + Ok(()) +} diff --git a/crates/benilla-formats/src/characters/geosets.rs b/crates/benilla-formats/src/characters/geosets.rs index 5e466d5f..105f98ac 100644 --- a/crates/benilla-formats/src/characters/geosets.rs +++ b/crates/benilla-formats/src/characters/geosets.rs @@ -150,6 +150,22 @@ impl CharacterGeosets { } /// Load both customization DBCs from the patch chain. + /// + /// **A duplicated `(race, sex, variation)` key resolves to the FIRST row** (decision 0682). Both + /// consumers are *linear scans* of the loaded table — `0x478540` for the hair geoset, `0x478740` + /// for the facial-hair record (wow-re RF-0073; `object-layer/scratch/r1.md:358` calls `0x478540` + /// "a loaded-DBC linear scan, NOT a pool") — so the earliest matching record is the one the + /// reference returns, where a last-write `HashMap::insert` would take the latest. + /// + /// It matters exactly once in the shipped data, and it was B11: `CharHairGeosets` carries **four** + /// rows for **(race 9 goblin, sex 0, variation 0)** — ids 241–244, geosets 1 / 2 / 1 / 2, the only + /// duplicated key in the file (243/244 read as the goblin-*female* pair with a zeroed sex column, + /// which is why goblin females, having no row at all, were always right). Geoset 1 is the bare + /// scalp; geoset 2 is that scalp **plus** a 38-vertex topknot whose texture slot is `Monster1` — + /// a slot no character-model goblin display fills, so it drew flat white (decision 0157). All 314 + /// character-model goblin displays select variation 0, so last-write put a white tuft on every one + /// of them. `CharacterFacialHairStyles` has no duplicate keys today; it takes the same rule + /// because the same scan law governs it. pub fn load(chain: &mut Chain) -> Result { let hair = { let bytes = chain @@ -162,7 +178,9 @@ impl CharacterGeosets { if let (Some(race), Some(sex), Some(var), Some(geoset)) = (u32_at(r, 1), u32_at(r, 2), u32_at(r, 3), u32_at(r, 4)) { - m.insert((race as u8, sex as u8, var as u8), geoset); + // FIRST row wins — see the duplicate-key note on [`CharacterGeosets::load`]. + m.entry((race as u8, sex as u8, var as u8)) + .or_insert(geoset); } } m @@ -187,7 +205,8 @@ impl CharacterGeosets { u32_at(r, 7).unwrap_or(0), u32_at(r, 8).unwrap_or(0), ]; - m.insert((race as u8, sex as u8, var as u8), g); + // FIRST row wins — see the duplicate-key note on [`CharacterGeosets::load`]. + m.entry((race as u8, sex as u8, var as u8)).or_insert(g); } } m @@ -216,7 +235,7 @@ impl CharacterGeosets { } /// CharHairGeosets.dbc — 6 fields in build 5875 (verified against the file header). -fn char_hair_geosets_schema() -> Schema { +pub(crate) fn char_hair_geosets_schema() -> Schema { let mut s = Schema::new("CharHairGeosets"); for (name, ty) in [ ("ID", FieldType::UInt32), @@ -233,7 +252,7 @@ fn char_hair_geosets_schema() -> Schema { /// HelmetGeosetVisData.dbc — 6 fields in build 5875 (verified: the loader `0x546f00` asserts /// fieldCount 6 / recordSize 0x18; the 5 mask columns are **race** bitmasks, RF-0083). -fn helmet_vis_schema() -> Schema { +pub(crate) fn helmet_vis_schema() -> Schema { let mut s = Schema::new("HelmetGeosetVisData"); for (name, ty) in [ ("ID", FieldType::UInt32), @@ -249,7 +268,7 @@ fn helmet_vis_schema() -> Schema { } /// CharacterFacialHairStyles.dbc — 9 fields in build 5875 (verified); no ID column (keyed race/sex/var). -fn char_facial_hair_schema() -> Schema { +pub(crate) fn char_facial_hair_schema() -> Schema { let mut s = Schema::new("CharacterFacialHairStyles"); for (name, ty) in [ ("RaceID", FieldType::UInt32), @@ -420,4 +439,67 @@ mod tests { let set = cg.visible_geosets(1, 0, 0, 0, &EquipGeosets::default()); assert!(set.contains(&1), "max(1, 0) = scalp geoset 1"); } + + /// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent). + fn vanilla_data_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") + } + + /// **B11, against the shipped DBC** (decision 0682): `CharHairGeosets` carries four rows for the + /// goblin male's only hairstyle, and the reference's linear scan takes the first — geoset **1**, + /// the bare scalp. Last-write took id 244's geoset **2**, which adds an untextured topknot. + /// + /// Three things are pinned here because each on its own could rot: that the shipped file really + /// does duplicate that key (the defect only exists if it does), that we resolve it to 1, and that + /// **no other key in the table is duplicated** — which is what bounds this change to goblins. + #[test] + fn goblin_male_hair_takes_the_first_of_four_duplicate_rows() { + 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"); + + // The file's own rows, read independently of the loader, so the test states the premise it + // rests on rather than assuming it. + let bytes = chain.read_file(CHAR_HAIR_GEOSETS).expect("CharHairGeosets"); + let rs = parse(&bytes, char_hair_geosets_schema(), "CharHairGeosets").expect("parse"); + let mut keys: HashMap<(u32, u32, u32), Vec> = HashMap::new(); + for r in rs.records() { + if let (Some(race), Some(sex), Some(var), Some(geoset)) = + (u32_at(r, 1), u32_at(r, 2), u32_at(r, 3), u32_at(r, 4)) + { + keys.entry((race, sex, var)).or_default().push(geoset); + } + } + let dups: Vec<_> = keys.iter().filter(|(_, v)| v.len() > 1).collect(); + assert_eq!( + dups.len(), + 1, + "exactly one duplicated (race,sex,variation) key ships; got {dups:?}" + ); + assert_eq!( + keys.get(&(9, 0, 0)).map(Vec::as_slice), + Some([1u32, 2, 1, 2].as_slice()), + "the goblin-male block is the duplicate, in file order" + ); + + // And the loader resolves it the reference's way. + let cg = CharacterGeosets::load(&mut chain).expect("load customization tables"); + let set = cg.visible_geosets(9, 0, 0, 0, &EquipGeosets::default()); + assert!( + set.contains(&1), + "goblin male shows the bare scalp (geoset 1)" + ); + assert!( + !set.contains(&2), + "goblin male does NOT show geoset 2 — the white topknot of B11" + ); + // The goblin female has no row at all (race 9 ships only sex-0 rows), so she falls to the + // region base, which is the same scalp. She was never affected; assert it so a future + // "fix" to the sex column has to notice her. + let f = cg.visible_geosets(9, 1, 0, 0, &EquipGeosets::default()); + assert!(f.contains(&1) && !f.contains(&2), "goblin female unchanged"); + } } diff --git a/crates/benilla-formats/src/characters/mod.rs b/crates/benilla-formats/src/characters/mod.rs index 0d98b064..f66750c2 100644 --- a/crates/benilla-formats/src/characters/mod.rs +++ b/crates/benilla-formats/src/characters/mod.rs @@ -26,3 +26,8 @@ mod sections; pub use customization::{CharCreateCatalog, DialRanges, StartOutfitItem}; pub use geosets::{CharacterGeosets, EquipGeosets}; pub use sections::CharSections; + +// The appearance tables' schemas, for the `benilla-extract dbc` CSV dump (`crate::schema_for`) — +// the same constructors the loaders above use, never a second transcription of the same layout. +pub(crate) use geosets::{char_facial_hair_schema, char_hair_geosets_schema, helmet_vis_schema}; +pub(crate) use sections::char_sections_schema; diff --git a/crates/benilla-formats/src/characters/sections.rs b/crates/benilla-formats/src/characters/sections.rs index fefa7e46..9b1bafef 100644 --- a/crates/benilla-formats/src/characters/sections.rs +++ b/crates/benilla-formats/src/characters/sections.rs @@ -347,7 +347,7 @@ fn blit_over(dst: &mut BlpMipChain, src: &BlpMipChain, tile: Tile) { } /// CharSections.dbc — 10 fields in build 5875 (verified); 3 string columns are texture-name offsets. -fn char_sections_schema() -> Schema { +pub(crate) fn char_sections_schema() -> Schema { let mut s = Schema::new("CharSections"); for (name, ty) in [ ("ID", FieldType::UInt32), diff --git a/crates/benilla-formats/src/creatures.rs b/crates/benilla-formats/src/creatures.rs index 6349e4e3..aed66442 100644 --- a/crates/benilla-formats/src/creatures.rs +++ b/crates/benilla-formats/src/creatures.rs @@ -189,7 +189,7 @@ impl CreatureCatalog { } /// CreatureModelData.dbc — 16 fields in build 5875 (no `mountHeight`). We read ID, ModelName, scale. -fn creature_model_data_schema() -> Schema { +pub(crate) fn creature_model_data_schema() -> Schema { let mut s = Schema::new("CreatureModelData"); for (name, ty) in [ ("ID", FieldType::UInt32), @@ -215,7 +215,7 @@ fn creature_model_data_schema() -> Schema { } /// CreatureDisplayInfo.dbc — 12 fields in build 5875. We read ID, ModelID, scale, 3 skin textures. -fn creature_display_info_schema() -> Schema { +pub(crate) fn creature_display_info_schema() -> Schema { let mut s = Schema::new("CreatureDisplayInfo"); for (name, ty) in [ ("ID", FieldType::UInt32), @@ -242,7 +242,7 @@ fn creature_display_info_schema() -> Schema { /// cross-checked with vmangos `DBCStructure.h`). We read the appearance selectors, the 10 equipment /// columns (8..17 — `ItemDisplayInfo` display ids for the worn armor geosets + helm/shoulder attach), /// and the bake name. -fn creature_display_info_extra_schema() -> Schema { +pub(crate) fn creature_display_info_extra_schema() -> Schema { let mut s = Schema::new("CreatureDisplayInfoExtra"); for (name, ty) in [ ("ID", FieldType::UInt32), diff --git a/crates/benilla-formats/src/gameobjects.rs b/crates/benilla-formats/src/gameobjects.rs index cb03d872..567a7401 100644 --- a/crates/benilla-formats/src/gameobjects.rs +++ b/crates/benilla-formats/src/gameobjects.rs @@ -37,6 +37,12 @@ impl GameObjectCatalog { pub fn is_empty(&self) -> bool { self.models.is_empty() } + + /// Every `(displayId, model path)` pair, unordered — the corpus-sweep entry point for "which + /// models can a GameObject ever be, and which display ids reach each one". + pub fn iter(&self) -> impl Iterator { + self.models.iter().map(|(&id, p)| (id, p.as_str())) + } } /// GameObjectDisplayInfo.dbc — 12 fields in build 5875: ID, modelName, then 10 sound refs. diff --git a/crates/benilla-formats/src/items.rs b/crates/benilla-formats/src/items.rs index b10bd25e..e9c84a83 100644 --- a/crates/benilla-formats/src/items.rs +++ b/crates/benilla-formats/src/items.rs @@ -112,7 +112,7 @@ impl ItemDisplayCatalog { /// column pins). Unpinned integer columns are still typed `UInt32` (matching the file's own record /// stride) even though nothing reads them, so the schema's field-count check against the real header /// stays exact. -fn item_display_info_schema() -> Schema { +pub(crate) fn item_display_info_schema() -> Schema { let mut s = Schema::new("ItemDisplayInfo"); for (name, ty) in [ ("ID", FieldType::UInt32), diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index c9e0287d..b6bf19ac 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -63,7 +63,7 @@ pub use ground_effects::{ GroundEffectCatalog, }; mod light; -pub use light::{Atmosphere, LightCatalog}; +pub use light::{Atmosphere, LightCatalog, Submersion}; mod loading_screen; pub use loading_screen::{load_loading_screens, LoadingScreenCatalog}; mod liquid; @@ -74,6 +74,8 @@ mod anim_data; pub use anim_data::{load_anim_data_catalog, AnimDataCatalog, AnimEntry}; mod quest_headers; pub use quest_headers::{load_quest_header_names, QuestHeaderNames}; +mod area_trigger; +pub use area_trigger::{load_area_trigger_catalog, AreaTriggerCatalog, AreaTriggerRow}; mod area_sound; pub use area_sound::{ load_area_sound_catalog, AreaAudio, AreaEntry, AreaSoundCatalog, SoundAmbienceEntry, @@ -141,20 +143,20 @@ pub use models::{ accumulate_wmo_group_camera_collision, accumulate_wmo_group_collision, hand_grip_finger_poses, load_m2_animation_summary, load_m2_bounds, load_m2_collision_hull, load_m2_mesh, load_m2_mesh_skinned, load_object_model, load_wmo, load_wmo_collision_tris, - m2_ribbon_emitter_count, m2_texture_transform_count, parse_m2_animation_summary, - parse_m2_animations, parse_m2_attachments, parse_m2_bounds, parse_m2_camera, - parse_m2_collision_hull, parse_m2_event_markers, parse_m2_global_sequence_bones, - 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, PlayableAnim, RenderSubmesh, - RgbAnim, ScalarAnim, Skeleton, SkeletonBone, StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, - WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, - WmoPortals, WmoRoot, + m2_ribbon_emitter_count, m2_texture_transform_count, parse_m2_animation_lookup, + parse_m2_animation_summary, parse_m2_animations, parse_m2_attachments, parse_m2_bounds, + parse_m2_camera, parse_m2_collision_hull, parse_m2_event_markers, + parse_m2_global_sequence_bones, 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, PlayableAnim, RenderSubmesh, RgbAnim, ScalarAnim, Skeleton, SkeletonBone, + StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader, + WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot, }; mod terrain; pub use terrain::{ @@ -351,6 +353,40 @@ pub fn blp_bytes_to_mip_chain(bytes: &[u8]) -> Result { fn schema_for(dbc_name: &str) -> Option { let base = dbc_name.rsplit(['/', '\\']).next().unwrap_or(dbc_name); + // Tables whose schema a typed loader in this crate already declares: reuse *that* constructor + // rather than re-transcribing the layout here, so a dump can never disagree with what the client + // actually reads (the six hand-written schemas below predate any loader for their table). + // The appearance family is registered because "which row does this NPC render from?" is a + // recurring question; the crate has ~30 more loader schemas that could join this list one line + // at a time as they are needed. + for (name, ctor) in [ + ( + "CreatureDisplayInfo.dbc", + creatures::creature_display_info_schema as fn() -> Schema, + ), + ( + "CreatureDisplayInfoExtra.dbc", + creatures::creature_display_info_extra_schema, + ), + ( + "CreatureModelData.dbc", + creatures::creature_model_data_schema, + ), + ("CharHairGeosets.dbc", characters::char_hair_geosets_schema), + ( + "CharacterFacialHairStyles.dbc", + characters::char_facial_hair_schema, + ), + ("HelmetGeosetVisData.dbc", characters::helmet_vis_schema), + ("CharSections.dbc", characters::char_sections_schema), + ("ItemDisplayInfo.dbc", items::item_display_info_schema), + ("AreaTrigger.dbc", area_trigger::area_trigger_schema), + ] { + if base.eq_ignore_ascii_case(name) { + return Some(ctor()); + } + } + if base.eq_ignore_ascii_case("TaxiNodes.dbc") { // 16 fields (record_size 64), verified against build 5875. let mut s = Schema::new("TaxiNodes"); @@ -477,7 +513,9 @@ pub fn dbc_to_csv(dbc_bytes: &[u8], dbc_name: &str, out: &Path) -> Result<(u32, anyhow::anyhow!( "no schema defined for '{dbc_name}' ({fields} fields); known: TaxiNodes.dbc, \ AreaTable.dbc, GameObjectDisplayInfo.dbc, TaxiPath.dbc, TaxiPathNode.dbc, \ - TransportAnimation.dbc" + TransportAnimation.dbc, CreatureDisplayInfo.dbc, CreatureDisplayInfoExtra.dbc, \ + CreatureModelData.dbc, CharHairGeosets.dbc, CharacterFacialHairStyles.dbc, \ + HelmetGeosetVisData.dbc, CharSections.dbc, ItemDisplayInfo.dbc" ) })?; @@ -567,4 +605,44 @@ mod tests { ); assert_eq!(chain.mips[0].len(), 2 * 2 * 4); } + + /// Every table the CSV dumper claims in its error hint really has a schema, and each dumps + /// against the **shipped** file — which is the only check that matters, because `with_schema` + /// rejects a field-count mismatch and `dbc_to_csv` would otherwise fail only when a session + /// reached for it mid-investigation. Skips without the client data. + #[test] + fn registered_dbc_schemas_dump_the_shipped_tables() { + 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 = open_chain(&data).expect("open chain"); + let out = std::env::temp_dir().join("benilla-schema-registry-test.csv"); + for table in [ + "CreatureDisplayInfo", + "CreatureDisplayInfoExtra", + "CreatureModelData", + "CharHairGeosets", + "CharacterFacialHairStyles", + "HelmetGeosetVisData", + "CharSections", + "ItemDisplayInfo", + "TaxiNodes", + "AreaTable", + "GameObjectDisplayInfo", + "TaxiPath", + "TaxiPathNode", + "TransportAnimation", + ] { + let path = format!("DBFilesClient\\{table}.dbc"); + let bytes = chain + .read_file(&path) + .unwrap_or_else(|e| panic!("{path}: {e}")); + let (rows, _) = dbc_to_csv(&bytes, &path, &out) + .unwrap_or_else(|e| panic!("{table} schema does not fit the shipped file: {e}")); + assert!(rows > 0, "{table} has rows"); + } + let _ = std::fs::remove_file(&out); + } } diff --git a/crates/benilla-formats/src/light.rs b/crates/benilla-formats/src/light.rs index 7822fc3d..32fadb9e 100644 --- a/crates/benilla-formats/src/light.rs +++ b/crates/benilla-formats/src/light.rs @@ -48,9 +48,11 @@ const POS_SCALE: f32 = 36.0; /// Light.dbc weather param slots: [0]=clear, [1]=clear-underwater, [2]=storm, [3]=storm-underwater, /// [4]=death. const SLOT_CLEAR: usize = 0; -/// `[1]` clear-underwater: the denser/greener fog + cooler light the client switches to when the eye -/// is submerged (VERIFIED apitrace WoW.18: ~38 yd teal fog + blue ambient/diffuse vs the ~1284 yd -/// surface fog). Same band-row meanings as the clear slot, just a different `LightParams` id. +/// `[1]` clear-underwater: the denser fog + cooler light the client switches to when the eye is +/// submerged in **water** (VERIFIED apitrace WoW.18: ~38 yd teal fog + blue ambient/diffuse vs the +/// ~1284 yd surface fog). Same band-row meanings as the clear slot, just a different `LightParams` id. +/// Reached only by [`Submersion::Water`] — magma and slime never consult a zone slot at all +/// (see [`PARAM_MAGMA`]). const SLOT_CLEAR_UNDERWATER: usize = 1; const SLOT_STORM: usize = 2; const SLOT_STORM_UNDERWATER: usize = 3; @@ -60,9 +62,68 @@ const SLOT_STORM_UNDERWATER: usize = 3; /// priority over weather/underwater exactly as the single global slot does. const SLOT_DEATH: usize = 4; +/// The **fixed global `LightParams` rows** the reference uses for submersion in the two fullbright +/// liquids — byte-VERIFIED (`0x6d2371` routes magma and slime to their own leg *before* the slot +/// selection: magma at `0x6d23e1` reads row 7, slime at `0x6d239e` reads row 6, each guarded on the +/// table's `maxId`). These are **zone-independent** and nothing in `Light.dbc` references them, which +/// is why no position-keyed dump ever surfaced them: submersion in lava does not read the zone's +/// underwater slot at all. +/// +/// The shipped rows are exactly the vanilla look, which is the cross-check that earned this: +/// magma fog `[200,52,0]` at **27 yd** with start fraction **−2.0** (67 % fiery orange at the eye), +/// slime fog `[0,255,0]` at **50 yd** with **−1.0** (50 % green). There is no separate magma/slime +/// fog constant anywhere in the binary (VERIFIED negative) — these two rows *are* the distinct look. +const PARAM_SLIME: u32 = 6; +const PARAM_MAGMA: u32 = 7; + +/// What the camera eye is submerged in, if anything — the atmosphere selector. Water and ocean pick +/// the zone's *underwater slot*; magma and slime replace the whole area blend with a fixed global row +/// (see [`PARAM_MAGMA`]/[`PARAM_SLIME`]). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Submersion { + /// Dry — the ordinary clear/storm slot. + #[default] + Dry, + /// Under water, ocean or rapids: the zone's own underwater `LightParams`, area-blended as usual. + Water, + /// Under magma: the fixed global row, verbatim. + Magma, + /// Under slime: the fixed global row, verbatim. + Slime, +} + +impl Submersion { + /// Submerged in **any** liquid. + pub fn any(self) -> bool { + self != Submersion::Dry + } + + /// Submerged specifically in a WATER kind (water / ocean / rapids) — the predicate for the + /// consumers that are about water rather than about liquid: the `UnderWaterLoop` ambience bed and + /// the "Underwater" reverb-preference column. + /// + /// Deliberately NOT [`Self::any`]: whether the reference swaps the submerged ambience bed and the + /// reverb preset for **lava and slime** as well is unverified, and widening it silently on the way + /// past would be inventing a behaviour. The atmosphere is verified per kind; the audio is not. + pub fn is_water(self) -> bool { + self == Submersion::Water + } + + /// The fixed global `LightParams` row this submersion replaces the whole blend with, if any. + /// `None` for [`Submersion::Dry`] and [`Submersion::Water`], which go through the slot selection. + fn fixed_param(self) -> Option { + match self { + Submersion::Magma => Some(PARAM_MAGMA), + Submersion::Slime => Some(PARAM_SLIME), + Submersion::Dry | Submersion::Water => None, + } + } +} + /// Pick the `LightParams` slot for the current ghost/weather/submersion state (see `SLOT_*`). /// Ghost wins outright — the client keeps ONE active slot, and the ghost watcher owns it while -/// the flag is up. +/// the flag is up. Only [`Submersion::Water`] reaches the underwater slots; the fullbright liquids +/// never get here (their fixed row short-circuits the blend). fn weather_slot(ghost: bool, stormy: bool, underwater: bool) -> usize { if ghost { return SLOT_DEATH; @@ -304,6 +365,19 @@ impl LightCatalog { } x => x, }; + self.debug_param(p, time); + } + + /// Debug: [`Self::debug_bands`] for an explicitly named `LightParams` id, with no position and no + /// slot selection. The instrument for the rows **nothing in `Light.dbc` references**: magma and + /// slime submersion do not read a zone's underwater slot at all, they read the fixed global rows + /// 7 and 6 (VERIFIED `0x6d2371`, guards `maxId >= 7`/`>= 6`), which no position-keyed dump can + /// ever reach. + pub fn debug_param(&self, p: u32, time: u32) { + if p < 1 { + println!("(LightParams ids are 1-based)"); + return; + } println!("LightParams {p} @ time {time} half-min — all int rows (sRGB 0..255):"); for b in 0..18u32 { let key = (p - 1) * 18 + b + 1; @@ -348,10 +422,20 @@ impl LightCatalog { pos: [f32; 3], time: u32, stormy: bool, - underwater: bool, + submersion: Submersion, ghost: bool, ) -> Atmosphere { - let slot = weather_slot(ghost, stormy, underwater); + // Magma/slime submersion replaces the WHOLE area blend with one fixed global row — the + // reference branches out at `0x6d2371`, before the slot selection, so there is no zone + // sphere blend and no storm lerp on this leg (see [`PARAM_MAGMA`]). Guarded on the row + // actually existing, matching the binary's own `maxId` guard: a chain without it falls + // through to the ordinary atmosphere rather than inventing one. + if let Some(p) = submersion.fixed_param() { + if self.has_param(p) { + return self.sample_param(p, time); + } + } + let slot = weather_slot(ghost, stormy, submersion == Submersion::Water); let atmo_of = |l: &Light| -> Option { // Fall back to the clear slot if the requested slot is unset for this light (e.g. a zone // with no dedicated underwater param degrades to the above-water atmosphere). @@ -453,7 +537,7 @@ impl LightCatalog { ); } let picked = self.sample(map, pos, time, false); - let blended = self.sample_blended(map, pos, time, false, false, false); + let blended = self.sample_blended(map, pos, time, false, Submersion::Dry, false); println!( " => pick_light : amb {} sun {}", c(picked.ambient), @@ -466,6 +550,87 @@ impl LightCatalog { ); } + /// Debug: the **per-weather-slot** resolve at `pos` — for each of the five `Light.dbc` param + /// slots (clear / clear-underwater / storm / storm-underwater / death), which `LightParams` id + /// the picked sphere and the continent global each name, whether the slot is UNSET (and so falls + /// back to clear), and the fog/ambient/diffuse the blend actually resolves. + /// + /// The companion to [`Self::debug_bands`], which only ever dumps the clear slot: an underwater or + /// ghost atmosphere that reads wrong is either a slot the data leaves unset (→ we correctly show + /// the surface atmosphere) or a slot whose values we mis-consume, and those two look identical + /// from inside the game. This tells them apart in one command. + pub fn debug_slots(&self, map: u32, pos: [f32; 3], time: u32) { + const NAMES: [&str; 5] = [ + "clear", + "clear-underwater", + "storm", + "storm-underwater", + "death", + ]; + let c = |x: [f32; 3]| { + format!( + "[{:3} {:3} {:3}]", + (x[0] * 255.0) as u8, + (x[1] * 255.0) as u8, + (x[2] * 255.0) as u8 + ) + }; + let picked = self.pick_light(map, pos); + let global = self.lights.iter().find(|l| l.map == map && l.global); + println!( + "Light slots on map {map} at [{:.1} {:.1} {:.1}], time {time} half-min:", + pos[0], pos[1], pos[2] + ); + match picked { + Some(l) if l.global => { + println!(" picked sphere : (none local — the continent global)") + } + Some(l) => println!( + " picked sphere : local, falloff {:.0}->{:.0} yd, params {:?}", + l.falloff_start, l.falloff_end, l.params + ), + None => println!(" picked sphere : (no light covers this position)"), + } + if let Some(g) = global { + println!(" continent glob: params {:?}", g.params); + } + println!( + " {:<17} {:>6} {:>6} {:>9} {:>6} {:<15} {:<15} {:<15}", + "slot", "picked", "global", "fog_end", "frac", "fog_color", "ambient", "diffuse" + ); + for (slot, name) in NAMES.iter().enumerate() { + // The same UNSET→clear fallback both sample paths apply, reported per source so an + // "unset everywhere" slot is visibly distinct from one carrying real values. + let show = |l: Option<&Light>| match l.map(|l| l.params[slot]) { + None => " -".to_string(), + Some(0) => " unset".to_string(), + Some(p) => format!("{p:>6}"), + }; + let a = self.sample_blended( + map, + pos, + time, + slot == SLOT_STORM || slot == SLOT_STORM_UNDERWATER, + if slot == SLOT_CLEAR_UNDERWATER || slot == SLOT_STORM_UNDERWATER { + Submersion::Water + } else { + Submersion::Dry + }, + slot == SLOT_DEATH, + ); + println!( + " {name:<17} {} {} {:>9.1} {:>6.2} {:<15} {:<15} {:<15}", + show(picked), + show(global), + a.fog_end, + a.fog_start_frac, + c(a.fog_color), + c(a.ambient), + c(a.sun_diffuse) + ); + } + } + fn pick_light(&self, map: u32, pos: [f32; 3]) -> Option<&Light> { let mut local: Option<&Light> = None; let mut global: Option<&Light> = None; @@ -484,6 +649,15 @@ impl LightCatalog { local.or(global) } + /// Whether `LightParams` `p` actually has band rows in this chain — the client's `maxId` guard on + /// the fixed magma/slime rows. Checks the fog-colour row, which every real record carries. + fn has_param(&self, p: u32) -> bool { + p >= 1 + && self + .int_bands + .contains_key(&((p - 1) * 18 + IB_FOG_COLOR + 1)) + } + fn sample_param(&self, p: u32, t: u32) -> Atmosphere { let ib = |b: u32| self.int_bands.get(&((p - 1) * 18 + b + 1)); let fb = |b: u32| self.float_bands.get(&((p - 1) * 6 + b + 1)); diff --git a/crates/benilla-formats/src/models/anim.rs b/crates/benilla-formats/src/models/anim.rs index a190af7c..da316a6d 100644 --- a/crates/benilla-formats/src/models/anim.rs +++ b/crates/benilla-formats/src/models/anim.rs @@ -200,6 +200,16 @@ pub fn parse_m2_playable_animation_lookup(bytes: &[u8]) -> Result Result> { + let format = + parse_m2(&mut Cursor::new(bytes)).map_err(|e| anyhow::anyhow!("parsing M2: {e}"))?; + Ok(format.model().animation_lookup.clone()) +} + /// One bone's keyframe tracks for an animation sequence (decision 0019), in **raw WoW model space**: /// translation/scale are `[f32;3]`, rotation is an uncompressed quaternion `[x,y,z,w]` (vanilla v256). /// Times are **seconds**, rebased to the sequence start so each clip runs `0..duration`. Only the diff --git a/crates/benilla-formats/src/models/wmo/group.rs b/crates/benilla-formats/src/models/wmo/group.rs index 6e3395c9..a681fa79 100644 --- a/crates/benilla-formats/src/models/wmo/group.rs +++ b/crates/benilla-formats/src/models/wmo/group.rs @@ -424,15 +424,22 @@ pub fn wmo_group_submeshes(group_bytes: &[u8], root: &WmoRoot) -> Result Vec<(u32, u32)> { + /// Present WDL tiles forming a Chebyshev **window** around a world `(x, y)`: every present tile + /// within `radius` tiles, **the centre one included** — the reference's far walk (`0x683040`) + /// visits a camera-centred ±3-tile window of the distant grid with no exclusion, and so must ours. + /// + /// There used to be a `skip_inner` parameter, and the renderer passed 0 — dropping the camera's + /// OWN tile, on the reasoning that a 533 yd tile sits entirely inside the far-clip wall and would + /// be discarded anyway. That holds only near the default `farclip` of 777. **Drop the view + /// distance below a tile — the vanilla range reaches down to 177 — and most of the camera's own + /// tile lies BEYOND the wall, where it is the only thing that can draw the near horizon.** Its + /// absence is then a gap between the detailed terrain and the distant hills, right above the + /// horizon line, through which the sky pours (decision 0684 — the director's Weazel's Crater + /// report at view distance 320). The parameter is gone rather than defaulted so the hole cannot + /// come back: what bounds the band on the near side is the far band's near plane in `wdl.wgsl`, + /// and nothing else. + pub fn tiles_in_ring(&self, world_x: f32, world_y: f32, radius: u32) -> Vec<(u32, u32)> { let (cx, cy) = benilla_wdt::world_to_tile(world_x, world_y); - let (r, inner) = (radius as i32, skip_inner as i32); + let r = radius as i32; let mut out = Vec::new(); for dy in -r..=r { for dx in -r..=r { - if dx.abs() <= inner && dy.abs() <= inner { - continue; // the detailed ring — drawn at full detail, so skip WDL here - } let (tx, ty) = (cx as i32 + dx, cy as i32 + dy); if (0..64).contains(&tx) && (0..64).contains(&ty) diff --git a/crates/benilla-formats/tests/chain.rs b/crates/benilla-formats/tests/chain.rs index f8116f39..6742c4aa 100644 --- a/crates/benilla-formats/tests/chain.rs +++ b/crates/benilla-formats/tests/chain.rs @@ -961,3 +961,178 @@ fn decodes_azeroth_wdl_against_apitrace_ground_truth() { assert!((xmax - (-8533.3)).abs() < 0.5, "tile origin X, got {xmax}"); assert!((ymax - (-1066.7)).abs() < 0.5, "tile origin Y, got {ymax}"); } + +/// The two **fixed global `LightParams` rows** magma and slime submersion read (`light::PARAM_MAGMA` +/// / `PARAM_SLIME`, byte-VERIFIED `0x6d2371`), pinned against the shipped client. +/// +/// This is the cross-check that earned the finding rather than merely repeating it: nothing in +/// `Light.dbc` references rows 6 or 7, so no position-keyed sample can reach them and no zone can +/// vouch for them. What vouches for them is that they decode to *exactly* the vanilla lava and slime +/// view — dense fiery orange and dense pure green — which a wrong pair of row numbers would not. +#[test] +fn magma_and_slime_submersion_read_the_fixed_global_light_params() { + 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 cat = benilla_formats::LightCatalog::load(&mut chain).expect("load Light DBCs"); + let to255 = |c: [f32; 3]| { + [ + (c[0] * 255.0).round() as i32, + (c[1] * 255.0).round() as i32, + (c[2] * 255.0).round() as i32, + ] + }; + + // Two positions on different continents whose CLEAR atmospheres differ — the fixed rows must be + // identical at both, because they are zone-independent. This is the property that separates "a + // fixed global row" from "a zone underwater slot that happens to look right at one pin". + let pins = [ + (0u32, [-7531.21f32, -1123.64, 172.58]), // Blackrock Mountain (the B24/B68 lava pin) + (1u32, [-5075.53f32, -2063.09, -50.10]), // Thousand Needles (the B68 water pin) + ]; + for kind in [ + benilla_formats::Submersion::Magma, + benilla_formats::Submersion::Slime, + ] { + let sampled: Vec<_> = pins + .iter() + .map(|(map, pos)| cat.sample_blended(*map, *pos, 1440, false, kind, false)) + .collect(); + assert_eq!( + to255(sampled[0].fog_color), + to255(sampled[1].fog_color), + "{kind:?} is zone-INDEPENDENT: the fixed row must not vary by position" + ); + assert_eq!( + sampled[0].fog_end, sampled[1].fog_end, + "{kind:?} fog end must not vary by position" + ); + } + + // Magma → row 7. Dense fiery orange, and the shortest fog in the client: 972/36 = 27 yd, with a + // −2.0 start fraction putting `1 − 27/(27+54)` = 67 % of it at the eye itself. + let magma = cat.sample_blended( + 0, + pins[0].1, + 1440, + false, + benilla_formats::Submersion::Magma, + false, + ); + assert_eq!( + to255(magma.fog_color), + [200, 52, 0], + "magma fog (row 7 int 7)" + ); + assert_eq!(to255(magma.ambient), [255, 55, 0], "magma ambient"); + assert!( + (magma.fog_end - 27.0).abs() < 0.01, + "magma fog end 972/36 = 27 yd, got {}", + magma.fog_end + ); + assert!( + (magma.fog_start_frac + 2.0).abs() < 1e-6, + "magma start fraction −2.0, got {}", + magma.fog_start_frac + ); + + // Slime → row 6. Pure green at 1800/36 = 50 yd, −1.0 start = 50 % at the eye. + let slime = cat.sample_blended( + 0, + pins[0].1, + 1440, + false, + benilla_formats::Submersion::Slime, + false, + ); + assert_eq!( + to255(slime.fog_color), + [0, 255, 0], + "slime fog (row 6 int 7)" + ); + assert_eq!(to255(slime.ambient), [0, 60, 0], "slime ambient"); + assert!( + (slime.fog_end - 50.0).abs() < 0.01, + "slime fog end 1800/36 = 50 yd, got {}", + slime.fog_end + ); + assert!( + (slime.fog_start_frac + 1.0).abs() < 1e-6, + "slime start fraction −1.0, got {}", + slime.fog_start_frac + ); + + // And the whole point: neither is the zone's own underwater atmosphere. At the Thousand Needles + // pin the water murk is the olive-brown LP 203 — being in lava there must not show it. + let water = cat.sample_blended( + 1, + pins[1].1, + 1440, + false, + benilla_formats::Submersion::Water, + false, + ); + assert_ne!( + to255(water.fog_color), + to255(magma.fog_color), + "lava must not inherit the zone's water murk" + ); + assert_ne!( + to255(water.fog_color), + to255(slime.fog_color), + "slime must not inherit the zone's water murk" + ); + // Dry is different again — the guard against "everything resolved to one fallback". + let dry = cat.sample_blended( + 1, + pins[1].1, + 1440, + false, + benilla_formats::Submersion::Dry, + false, + ); + assert_ne!( + to255(dry.fog_color), + to255(water.fog_color), + "the water pin's underwater slot must differ from its clear slot" + ); +} + +/// The far band's tile window **contains the camera's own tile** (decision 0684). Dropping it was +/// invisible at the default view distance — a 533 yd tile sits inside a 777 yd wall, so it would +/// have been discarded anyway — and a hole above the horizon at any lower one, where the own tile is +/// the only thing that draws the near horizon. The director found it at view distance 320 in +/// Weazel's Crater; this pins the window at the coordinates they reported. +#[test] +fn the_wdl_window_contains_the_cameras_own_tile() { + 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 wdl = benilla_formats::WdlFile::load(&mut chain, "Kalimdor").expect("load Kalimdor.wdl"); + + // Weazel's Crater, Thousand Needles — the report's spot. The client's debug panel read + // `tile 39,42` standing there, which is what `world_to_tile` must agree on. + let (x, y) = (-5841.9_f32, -3802.4); + let own = benilla_wdt::world_to_tile(x, y); + assert_eq!(own, (39, 42), "the reported tile for Weazel's Crater"); + assert!(wdl.is_present(own.0, own.1), "the own tile is authored"); + + let window = wdl.tiles_in_ring(x, y, 5); + assert!( + window.contains(&own), + "the camera's own WDL tile must be in the window — without it the near horizon is a hole" + ); + // And it is a full (2r+1)² window, not a ring: every present tile within the radius. + let expected = (-5i32..=5) + .flat_map(|dy| (-5i32..=5).map(move |dx| (own.0 as i32 + dx, own.1 as i32 + dy))) + .filter(|&(tx, ty)| (0..64).contains(&tx) && (0..64).contains(&ty)) + .filter(|&(tx, ty)| wdl.is_present(tx as u32, ty as u32)) + .count(); + assert_eq!(window.len(), expected, "every present tile in the window"); +} diff --git a/crates/benilla-formats/tests/m2_go_arm_book.rs b/crates/benilla-formats/tests/m2_go_arm_book.rs new file mode 100644 index 00000000..eeb610cb --- /dev/null +++ b/crates/benilla-formats/tests/m2_go_arm_book.rs @@ -0,0 +1,146 @@ +//! Every book in the world opened and closed constantly (`#bugs` B36) — because the loader-idle +//! seed armed the model's **file-order-first** sequence, and on a book that sequence is the *Close +//! motion*, not the closed pose. +//! +//! `G_BookOpenMediumBrown.m2` is the model that makes this unmissable. Its four sequences are the +//! whole door family — Close(146) / Closed(147) / Open(148) / Opened(149) — authored in **that** +//! order, so file-order-0 is a 0.333 s motion that sweeps bone 0 from the open angle to the closed +//! one. Every one of the four carries `flags` bit 0 clear, i.e. the kernel loops it forever (wow-re +//! `gameobject-anim-arm.md` §3, `0x714585`). Looping the Close motion is therefore a book that snaps +//! open and swings shut three times a second, for ever — exactly what was reported. +//! +//! The reference arms **animation id 0 (Stand)** resolved through the model's own +//! `playableAnimationLookup` (§1, byte-verified `0x71019b`), and this book's table sends id 0 to +//! **147 Closed** — a two-key band whose keys are both identity, i.e. a still, shut book. +//! +//! The DuelingFlag test next door pins the same seed law on a model whose first sequence is a +//! *Spawn*; this one pins it where the wrong pick is a **motion**, which is the visually loudest +//! failure and the one a whole class of GameObjects (every `World\Goober\` door-family prop) shares. +//! Skips (passes) when the client isn't present at `/WoW/Data`. + +use std::path::PathBuf; + +use benilla_formats::{ + open_chain, parse_m2_animation_lookup, parse_m2_animations, parse_m2_playable_animation_lookup, +}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +const BOOK: &str = "World\\Goober\\G_BookOpenMediumBrown.m2"; +/// `AnimationData.dbc`: 146 Close (motion), 147 Closed (rest), 148 Open (motion), 149 Opened (rest). +const CLOSE: u16 = 146; +const CLOSED: u16 = 147; +const OPEN: u16 = 148; + +#[test] +fn the_book_idle_resolves_to_closed_not_the_close_motion() { + 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 bytes = chain.read_file(BOOK).expect("the book M2 is in the chain"); + let anims = parse_m2_animations(&bytes); + + // 1. File order really does lead with the Close MOTION — without this the test proves nothing. + assert_eq!( + anims.first().map(|a| a.anim_id), + Some(CLOSE), + "the model's FIRST sequence is the Close motion, not a rest pose — the whole point" + ); + + // 2. That first sequence loops (bit 0 clear), so arming it is a permanent flap, not a one-shot. + assert!( + anims[0].looping, + "Close is a looping band — arming it is what made the book cycle for ever" + ); + + // 3. The loader's seed resolves to Closed through the model's own table. + let playable = parse_m2_playable_animation_lookup(&bytes).expect("playable lookup"); + let idle_id = playable.first().map_or(0, |p| p.resolved_id); + assert_eq!( + idle_id, CLOSED, + "playableAnimationLookup[0] resolves to Closed — the seed the reference arms" + ); + + // 4. The pose each band actually holds, as the viewer would see it. This is the assertion that + // catches the bug coming back even if the id plumbing is rewritten. Bone 0's rotation is a + // pure x-axis quaternion: x ≈ 0 is the shut book, x ≈ 0.69 (≈ 87°) the open one. + // + // Closed's band (533..633 ms) has NO keys of its own — the authored keys sit at 500 and 667, + // both identity — so the window rule (decision 0643) resolves it to a single constant key. + // One key here is the *correct* answer, not a missing one: the band genuinely doesn't move. + let root_rot = |id: u16| -> Vec<[f32; 4]> { + anims + .iter() + .find(|a| a.anim_id == id) + .and_then(|a| a.bones.iter().find(|b| b.bone == 0)) + .map(|b| b.rotation.iter().map(|(_, q)| *q).collect()) + .unwrap_or_default() + }; + let closed = root_rot(CLOSED); + assert!(!closed.is_empty(), "Closed must resolve to a pose"); + for q in &closed { + assert!( + q[0].abs() < 1e-3, + "Closed must hold the SHUT pose (bone 0 x ≈ 0), got {}", + q[0] + ); + } + + // Close, by contrast, sweeps: it starts at the open angle and lands shut. Looping *that* is + // the flapping book — so both halves of the claim are pinned, not just the one we fixed. + let close_motion = root_rot(CLOSE); + let (lo, hi) = close_motion + .iter() + .fold((f32::MAX, f32::MIN), |(lo, hi), q| { + (lo.min(q[0]), hi.max(q[0])) + }); + assert!( + hi - lo > 0.5 && lo.abs() < 1e-3 && hi > 0.5, + "Close must swing from open ({hi}) to shut ({lo}) — that sweep, looped, IS the reported bug" + ); + + // 5. Closed is a non-BIND pose, which is what keeps the book on the skinned path at all: its + // two cover bones sit at ±2.5° while bind has them square. If the idle ever stopped counting + // as "differs from bind" the book would drop to the static mesh — silently, because the + // difference is small enough to pass an eyeball and large enough to be the wrong book. + let closed_seq = anims + .iter() + .find(|a| a.anim_id == CLOSED) + .expect("the book authors Closed"); + let tilted = closed_seq + .bones + .iter() + .filter(|b| { + b.rotation + .iter() + .any(|(_, q)| (q[3].abs() - 1.0).abs() > 1e-4) + }) + .count(); + assert!( + tilted >= 2, + "Closed must pose bones off bind (the covers), got {tilted}" + ); + + // 6. The ownership table the GameObject arm's missing-sequence remap branches on (the + // reference's `0x711960`): this model owns the whole door family and nothing beyond it, so + // no remap leg is reachable here and the arm plays the LUT id directly. + let lookup = parse_m2_animation_lookup(&bytes).expect("animation lookup"); + let owns = |id: u16| lookup.get(id as usize).is_some_and(|&s| s != 0xffff); + for id in [CLOSE, CLOSED, OPEN, 149] { + assert!(owns(id), "the book authors animation id {id}"); + } + assert!( + !owns(0), + "the book authors NO Stand — the seed reaches Closed only through the playable table, \ + which is exactly why a naive `find(Stand)` would arm nothing" + ); + assert!( + !owns(150), + "ids past the table's end read as the out-of-bounds sentinel, i.e. not owned" + ); +} diff --git a/crates/benilla-m2/src/lib.rs b/crates/benilla-m2/src/lib.rs index c787da02..a86a5ad0 100644 --- a/crates/benilla-m2/src/lib.rs +++ b/crates/benilla-m2/src/lib.rs @@ -82,7 +82,11 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result { p += 8; let _anim = arr(p)?; p += 8; - let _anim_lookup = arr(p)?; + // AnimationLookup (header `+0x24`/`+0x28`): `AnimationData.dbc` id → this model's first sequence + // slot for it, `0xffff` where the model doesn't author it. The reference's "does this model own + // animation id X" predicate (`0x711960`) is exactly a bounds-checked read of this table, so it is + // parsed rather than inferred from the sequence list — see [`M2Model::owns_animation`]. + let animation_lookup_arr = arr(p)?; p += 8; // PlayableAnimationLookup (decision 0082, header `+0x2c`/`+0x30`, pre-Wrath only — dropped past // version 263, which `parse_m2`'s own top guard already excludes, so this is unconditional on any @@ -311,6 +315,18 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result { }); } + // AnimationLookup: one u16 each — the id → sequence-slot table `owns_animation` reads. + let al_avail = b.len().saturating_sub(animation_lookup_arr.1 as usize); + let mut animation_lookup = + Vec::with_capacity(capped(animation_lookup_arr.0 as usize, 2, al_avail)); + for i in 0..animation_lookup_arr.0 as usize { + animation_lookup.push( + get(animation_lookup_arr.1 as usize + i * 2, 2)? + .u16_at(0) + .ok_or(Error::Truncated)?, + ); + } + // PlayableAnimationLookup (decision 0082): one dword each, low16 resolved id / high16 dir flags. let pal_avail = b .len() @@ -457,6 +473,7 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result { pivot_attach_z, attachments: attachment_list, event_markers, + animation_lookup, playable_animation_lookup, views, version, diff --git a/crates/benilla-m2/src/model.rs b/crates/benilla-m2/src/model.rs index c8941c2c..0de29fa7 100644 --- a/crates/benilla-m2/src/model.rs +++ b/crates/benilla-m2/src/model.rs @@ -268,6 +268,12 @@ pub struct M2Model { /// (`$CSL`/`$CSR`/`$CST`), the ranged release (`$BWR`), the bow anchors, the sound/impact tags' /// anchor points. pub event_markers: Vec, + /// The model's **AnimationLookup** (header `+0x24`/`+0x28`): row `i` is the file slot of this + /// model's first sequence for `AnimationData.dbc` id `i`, or `0xffff` where it authors none. + /// This is the table behind the reference's ownership predicate — see [`M2Model::owns_animation`]. + /// Note the table is *short*: a model's `nAnimationLookup` stops just past the highest id it + /// authors, so an id beyond the end reads as the out-of-bounds sentinel, i.e. **not owned**. + pub animation_lookup: Vec, /// The model's baked **PlayableAnimationLookup** (decision 0082 — see [`M2PlayableAnim`]): row `i` /// is the model's own precomputed substitute for requested `AnimationData.dbc` id `i`. Empty for a /// version past 263 (the array is dropped) or a model whose header couldn't be read that far — the @@ -278,6 +284,22 @@ pub struct M2Model { pub(crate) version: u32, } +impl M2Model { + /// **Does this model author `AnimationData.dbc` id `anim_id`?** — the reference's `0x711960`, + /// byte-for-byte: a bounds-checked read of [`Self::animation_lookup`] (`0x710310` returns the + /// `0xffff` sentinel for an out-of-range index) compared against `0xffff`. + /// + /// This is the predicate the GameObject animation arm branches on when the substate's animation + /// id is missing from the model (wow-re `gameobject-anim-arm.md` §2c's four-way remap), so it + /// must answer exactly as the reference does — including for an id past the table's end, which + /// is **not owned** even if some later mechanism could reach a sequence carrying it. + pub fn owns_animation(&self, anim_id: u16) -> bool { + self.animation_lookup + .get(anim_id as usize) + .is_some_and(|&slot| slot != 0xffff) + } +} + /// Holds the parsed model (mirrors the reference's `parse_m2(..).model()` shape). pub struct M2Format { pub(crate) model: M2Model, diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 442c8ccd..f2cd7a4b 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -443,6 +443,11 @@ pub enum SessionEvent { /// A server notice (`SMSG_NOTIFICATION`) — pre-formatted text the real client flashes in the /// red UIErrorsFrame ("You do not know that language", trade refusals, and kin). Notification { text: String }, + /// An area trigger refused (`SMSG_AREA_TRIGGER_MESSAGE`) — pre-formatted text explaining why a + /// portal or instance entrance did nothing ("You must be at least level 58 to enter…", "You + /// cannot enter … while in ghost form."). The reference sends it to the same system-message + /// sink as [`Self::Notification`]. + AreaTriggerMessage { text: String }, /// Answers `/played` (`SMSG_PLAYED_TIME`, our `CMSG_PLAYED_TIME`): total played time + time /// since the last level-up, both in seconds. PlayedTime { total: u32, level: u32 }, diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index c09dcd3e..8e98de5e 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -98,6 +98,9 @@ pub fn decode(packet: ServerPacket) -> Vec { } ServerPacket::ChatWrongFaction => vec![SessionEvent::ChatWrongFaction], ServerPacket::Notification { text } => vec![SessionEvent::Notification { text }], + ServerPacket::AreaTriggerMessage { text } => { + vec![SessionEvent::AreaTriggerMessage { text }] + } ServerPacket::PlayedTime { total, level } => { vec![SessionEvent::PlayedTime { total, level }] } diff --git a/crates/benilla-protocol/src/messages/area_trigger.rs b/crates/benilla-protocol/src/messages/area_trigger.rs new file mode 100644 index 00000000..c7e24693 --- /dev/null +++ b/crates/benilla-protocol/src/messages/area_trigger.rs @@ -0,0 +1,61 @@ +//! The area-trigger pair: the client's "I walked into trigger N" report, and the server's refusal +//! text that sometimes comes back. +//! +//! An `AreaTrigger.dbc` volume means nothing to the client — it owns the geometry and nothing else. +//! Walking into one sends [`area_trigger`] and the **server** decides what happens: a teleport +//! (every instance entrance, the Darnassus/Rut'theran portals), a quest's explore objective, the +//! inn's rested state, a battleground's entrance list. A teleport comes back as the ordinary +//! `SMSG_TRANSFER_PENDING` + `SMSG_NEW_WORLD` pair (decision 0455) or a same-map +//! `MSG_MOVE_TELEPORT_ACK`; a refusal comes back as [`read_area_trigger_message`]'s text. + +use std::io; + +use crate::wire::{read_cstring, read_u32_le}; + +/// Body of `CMSG_AREATRIGGER` (opcode `0xB4`/180 — VERIFIED vmangos `Opcodes_1_12_1.h:183`, and the +/// reference builds exactly this: `0x5e2110` writes `0xb4` then the record's first field, its id). +/// One `u32`: the `AreaTrigger.dbc` id. +/// +/// The server re-checks the claim before acting — `HandleAreaTriggerOpcode` +/// (`Handlers/MiscHandler.cpp:622`) drops it unless the id is a real DBC row and the player is +/// inside the volume with 5 yd of slop — so a stale or invented id costs nothing but is never +/// obeyed. It is also dropped outright while taxi-flying. +pub fn area_trigger(trigger_id: u32) -> Vec { + trigger_id.to_le_bytes().to_vec() +} + +/// Read `SMSG_AREA_TRIGGER_MESSAGE` (VERIFIED vmangos `WorldSession::SendAreaTriggerMessage`, +/// `Server/WorldSession.cpp:882-898`: `u32 length` then the cstring): server-composed text +/// explaining why a trigger did *not* fire — "You must be at least level 58 to enter…", "You +/// cannot enter %s while in ghost form.", a battleground's level/faction refusal. +/// +/// The length prefix counts the cstring's bytes **including** its terminator; it is redundant with +/// the string itself, and is read and discarded (the reference does the same — its `0x2b8` arm +/// reads a u32, then a string, then displays the string). +pub(super) fn read_area_trigger_message(r: &mut &[u8]) -> io::Result { + let _length = read_u32_le(r)?; + read_cstring(r) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn body_is_the_bare_id() { + assert_eq!(area_trigger(542), 542u32.to_le_bytes().to_vec()); + } + + /// The length prefix is skipped and the text read whole — built the way the server builds it. + #[test] + fn message_reads_past_its_length_prefix() { + let text = "You must be at least level 58 to enter."; + let mut body = Vec::new(); + body.extend_from_slice(&(text.len() as u32 + 1).to_le_bytes()); + body.extend_from_slice(text.as_bytes()); + body.push(0); + let mut r = body.as_slice(); + assert_eq!(read_area_trigger_message(&mut r).unwrap(), text); + assert!(r.is_empty(), "the whole body is consumed"); + } +} diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 73d3f8b7..1ed5d741 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -16,6 +16,7 @@ //! `tests/common`). mod action_bar; +mod area_trigger; mod attack; mod bank; mod channel; @@ -53,6 +54,7 @@ mod world_state; pub use action_bar::{ set_action_button, ActionButton, ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL, }; +pub use area_trigger::area_trigger; pub use attack::{attack_swing, AttackerState}; pub use bank::{ autobank_item, autostore_bank_item, bank_slot_result, banker_activate, buy_bank_slot, diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index cc34ee3b..56aed377 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -219,6 +219,15 @@ pub const CMSG_USE_ITEM: u16 = 0x00AB; // 171 /// GameObject to use (decision 0236). Not interchangeable with `CMSG_LOOT`: the server rejects a /// GameObject guid on `CMSG_LOOT`, so a chest opens its loot through this opcode. pub const CMSG_GAMEOBJ_USE: u16 = 0x00B1; // 177 +/// VERIFIED vmangos `Opcodes_1_12_1.h:183`: 180 — and the reference writes this literal +/// (`0x5e2110`, its area-trigger check, builds `0xb4` + the trigger id). Body in +/// [`super::area_trigger`]: the `AreaTrigger.dbc` id the player just walked into. +pub const CMSG_AREATRIGGER: u16 = 0x00B4; // 180 +/// VERIFIED vmangos `Opcodes_1_12_1.h:697`: 696. Body in +/// [`super::area_trigger::read_area_trigger_message`] — why a trigger refused (level, ghost form, +/// battleground faction). The reference displays it through the same system-message sink as +/// `SMSG_NOTIFICATION` (`0x4945b0`). +pub const SMSG_AREA_TRIGGER_MESSAGE: u16 = 0x02B8; // 696 pub const CMSG_MESSAGECHAT: u16 = 0x0095; pub const SMSG_MESSAGECHAT: u16 = 0x0096; // The channel family (VERIFIED vmangos `Server/Protocol/Opcodes_1_12_1.h:154-171`, decimal diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index 9e5c09c7..259f14f6 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -329,6 +329,12 @@ pub enum ServerPacket { Notification { text: String, }, + /// `SMSG_AREA_TRIGGER_MESSAGE` — why an area trigger refused us ("You must be at least level 58 + /// to enter…"); `u32 length` + one cstring (vmangos `WorldSession::SendAreaTriggerMessage`, + /// `Server/WorldSession.cpp:882-898`). + AreaTriggerMessage { + text: String, + }, /// `SMSG_PLAYED_TIME` — answers our `CMSG_PLAYED_TIME` (`/played`): total played time + time /// since the last level-up, both in seconds (layout in [`super::chat::read_played_time`]). PlayedTime { @@ -1053,6 +1059,7 @@ impl ServerPacket { ServerPacket::ChatPlayerNotFound { .. } => "SMSG_CHAT_PLAYER_NOT_FOUND".into(), ServerPacket::ChatWrongFaction => "SMSG_CHAT_WRONG_FACTION".into(), ServerPacket::Notification { .. } => "SMSG_NOTIFICATION".into(), + ServerPacket::AreaTriggerMessage { .. } => "SMSG_AREA_TRIGGER_MESSAGE".into(), ServerPacket::PlayedTime { .. } => "SMSG_PLAYED_TIME".into(), ServerPacket::RandomRoll { .. } => "MSG_RANDOM_ROLL".into(), ServerPacket::InitialSpells { .. } => "SMSG_INITIAL_SPELLS".into(), diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index aac9a7a3..d075aea8 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -11,10 +11,10 @@ use crate::wire::{ }; use super::{ - action_bar, attack, bank, channel, chat, combat_log, death, duel, gameobject, gossip, group, - items, loot, mail, monster_move, movement, opcode, progression, quest, social, spellbook, - spells, taxi, trade, trainer, update_object, vendor, world_state, Character, CreatureQueryInfo, - ServerPacket, SpeedKind, + action_bar, area_trigger, attack, bank, channel, chat, combat_log, death, duel, gameobject, + gossip, group, items, loot, mail, monster_move, movement, opcode, progression, quest, social, + spellbook, spells, taxi, trade, trainer, update_object, vendor, world_state, Character, + CreatureQueryInfo, ServerPacket, SpeedKind, }; /// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`, @@ -343,6 +343,9 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result { opcode::SMSG_NOTIFICATION => ServerPacket::Notification { text: chat::read_notification(&mut r)?, }, + opcode::SMSG_AREA_TRIGGER_MESSAGE => ServerPacket::AreaTriggerMessage { + text: area_trigger::read_area_trigger_message(&mut r)?, + }, opcode::SMSG_PLAYED_TIME => { let (total, level) = chat::read_played_time(&mut r)?; ServerPacket::PlayedTime { total, level } diff --git a/crates/benilla-protocol/src/world/writer/area_trigger.rs b/crates/benilla-protocol/src/world/writer/area_trigger.rs new file mode 100644 index 00000000..a0bd3212 --- /dev/null +++ b/crates/benilla-protocol/src/world/writer/area_trigger.rs @@ -0,0 +1,24 @@ +//! The one area-trigger send. Body in [`crate::messages::area_trigger`], whose scope this mirrors. + +use anyhow::Result; + +use crate::messages::{self, opcode}; + +use super::WorldWriter; + +impl WorldWriter { + /// Report that we just walked into an `AreaTrigger.dbc` volume (`CMSG_AREATRIGGER`, layout in + /// [`messages::area_trigger`]) — the client's whole part in the trigger system: it owns the + /// geometry and names the id, and the **server** decides what that trigger means (a teleport, + /// a quest's explore objective, the inn's rested state, a battleground's entrance list). + /// + /// There is no dedicated success reply: a teleport arrives as the ordinary + /// `SMSG_TRANSFER_PENDING` + `SMSG_NEW_WORLD` pair or a same-map `MSG_MOVE_TELEPORT_ACK`, a + /// refusal as `SMSG_AREA_TRIGGER_MESSAGE`, and most triggers answer with nothing at all. + pub fn area_trigger(&mut self, trigger_id: u32) -> Result<()> { + self.send( + opcode::CMSG_AREATRIGGER, + &messages::area_trigger(trigger_id), + ) + } +} diff --git a/crates/benilla-protocol/src/world/writer/mod.rs b/crates/benilla-protocol/src/world/writer/mod.rs index 9a6ce39e..3c4e04c0 100644 --- a/crates/benilla-protocol/src/world/writer/mod.rs +++ b/crates/benilla-protocol/src/world/writer/mod.rs @@ -40,6 +40,7 @@ //! | [`mail`] | the mailbox | //! | [`pvp`] | the one PvP-flag toggle | //! | [`social`] | friends, ignores, and `/who` | +//! | [`area_trigger`] | the one "I walked into trigger N" report | use std::net::TcpStream; @@ -49,6 +50,7 @@ use benilla_srp::vanilla_header::EncrypterHalf; use super::send_packet; mod action_bar; +mod area_trigger; mod attack; mod bank; mod channel; diff --git a/crates/benilla/assets/shaders/wdl.wgsl b/crates/benilla/assets/shaders/wdl.wgsl index 0466f00d..03aed260 100644 --- a/crates/benilla/assets/shaders/wdl.wgsl +++ b/crates/benilla/assets/shaders/wdl.wgsl @@ -7,17 +7,57 @@ // is fog-coloured hill silhouettes occluding the (un-fogged) sky. Fog math + gamma handling mirror // terrain.wgsl exactly: planar eye-Z, GL_LINEAR, gamma space; the output is raw gamma — the buffer // holds bytes and the frame's one decode lives in the FFXGlow combine (GAMMA LANE, 0161). +// +// ## The far band is a BACKDROP, not the far half of a plane partition (decision 0684) +// +// The reference's far-band pass (`CWorldScene` drain `0x6841a0`, wow-re `terrain.md` + `rf35-render-list`) +// does NOT share a clip plane with the detailed world. It draws the coarse hulls under its own +// projection — `SetPerspective(fov, aspect, near = farclip − 33.0, far = horizonfarclip)`, byte-read at +// the two call sites `0x6842f2` (render) / `0x68317f` (the far walk's cull frustum), the `33.0` at +// `[0x8101b0]`, the horizon far from the runtime box `[0xc7b49c]` — into a **compressed depth range +// `[0.955, 0.96]`** (`[0x80febc]=0.96`, `[0x80fec4]=0.005`), i.e. squashed into the back half-percent of +// the depth buffer so it can never occlude anything the detailed world drew. Two properties fall out, +// and BOTH are load-bearing: +// +// * the band **overlaps** the detailed world by 33 yd (≈ one WDL outer cell, spacing 33.333) — so a +// coarse hull that crosses back inside the wall is still drawn, and no hole can open at the seam; +// * it is **depth-pushed**, never near-clipped at the wall — so that overlap cannot poke through the +// fine terrain, and nothing coarse near the camera can paint over the sky (its near plane stops it). +// +// We used to partition instead: terrain discarded past `farclip`, WDL discarded before it, sharing one +// plane with zero overlap. Two different surfaces cannot partition cleanly at one plane — wherever a ray +// grazed the coarse hull entirely INSIDE the wall while the fine mesh it approximates lay beyond it (a +// ridge crest, exactly), both passes discarded and the sky showed through as a band at the horizon +// (the director's "in-between area at the clip barrier", Weazel's Crater, 2026-07-26). +// +// Our depth buffer is Bevy's infinite REVERSE-Z (`perspective_infinite_reverse_rh`: depth = near/eye_z, +// 0 = infinitely far, and never exactly 0 for finite geometry), so the reference's `[0.955, 0.96]` +// window becomes a clamp rather than a `glDepthRange`: every detailed-world fragment lives at +// `depth ≥ near/farclip` (it is discarded past the wall), so clamping WDL just below that value puts the +// whole band behind the world by construction, while leaving it in front of the sky's forced `0.0` +// (`sky_order.rs`, "The depth law"). Order of the three backdrop tiers, all decided by depth and nothing +// else: sky (0.0) → WDL band → detailed world. #import bevy_pbr::{ mesh_functions, forward_io::Vertex, - view_transformations::position_world_to_clip, + view_transformations::{position_world_to_clip, view_z_to_depth_ndc}, mesh_view_bindings::view, } +/// How far the far band reaches back INSIDE the detailed world's far-clip wall — the reference's +/// far-band near plane `farclip − 33.0` (`[0x8101b0]`, one WDL outer cell). This overlap is what +/// closes the coarse-vs-fine seam; the depth clamp below is what keeps it harmless. +const WDL_OVERLAP: f32 = 33.0; + +/// Nudge off the wall depth so the band is strictly BEHIND a detailed-world fragment sitting exactly +/// at the wall, instead of tying it (reverse-Z `GreaterEqual` lets a tie through, and the winner would +/// then be draw order). Multiplicative so it can never cross zero into the sky. +const WDL_DEPTH_PUSH: f32 = 0.999; + struct WdlParams { fog_color: vec4, // rgb = Light.dbc row 7 (gamma 0..1); w = enable (>0.5 ⇒ blend) - fog_params: vec4, // x = fog_start yd; y = fog_end yd; zw reserved + fog_params: vec4, // x = fog_start yd; y = fog_end yd; z reserved; w = farclip wall (0 ⇒ off) }; @group(#{MATERIAL_BIND_GROUP}) @binding(100) var w: WdlParams; @@ -26,6 +66,11 @@ struct WdlVsOut { @location(0) world_position: vec4, } +struct WdlFsOut { + @location(0) color: vec4, + @builtin(frag_depth) depth: f32, +} + @vertex fn vertex(in: Vertex) -> WdlVsOut { var out: WdlVsOut; @@ -37,26 +82,36 @@ fn vertex(in: Vertex) -> WdlVsOut { } @fragment -fn fragment(in: WdlVsOut) -> @location(0) vec4 { +fn fragment(in: WdlVsOut) -> WdlFsOut { // PLANAR eye-Z (view-space depth), NOT radial — same as terrain.wgsl (apitrace-verified). Used for - // both the far-clip partition (below) and the fog. + // the band's near plane (below) and the fog. let eye_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z; - // INVERSE of the detailed-world far-clip wall: WDL draws ONLY beyond the wall (`fog_params.w` = - // farclip), while terrain/wow_model/liquid discard *beyond* it — so the detailed world and the WDL - // backdrop partition cleanly at the SAME plane (no overlap, no gap). WDL can therefore be drawn as a - // full ring (a constant backdrop) without toggling against the detailed streaming radius, which is - // what made the horizon pop in/out. The coarse-vs-fine height seam at the plane sits at ~farclip, - // deep in the fog, so it's hidden (the reference relies on the same). (0 ⇒ disabled.) - if (w.fog_params.w > 0.0 && eye_z < w.fog_params.w) { + let farclip = w.fog_params.w; + + // The far band's NEAR PLANE: `farclip − 33` (the reference's, see the header). Deliberately NOT the + // wall — the 33 yd overlap is what stops a hole opening at the coarse-vs-fine seam. (0 ⇒ disabled.) + if (farclip > 0.0 && eye_z < farclip - WDL_OVERLAP) { discard; } - // White vertex diffuse (the reference's WDL colour); fog does all the colouring. + + // White vertex diffuse (the reference's WDL colour); fog does all the colouring. Past `fog_end` + // — and `fog_end = min(zone end, farclip)`, so that is the whole band — this is pure fog colour. var rgb = vec3(1.0); if (w.fog_color.w > 0.5) { let denom = max(w.fog_params.y - w.fog_params.x, 0.001); let factor = clamp((w.fog_params.y - eye_z) / denom, 0.0, 1.0); rgb = mix(w.fog_color.xyz, rgb, factor); } + + var out: WdlFsOut; // GAMMA LANE (0161): raw gamma out; the frame decodes once in the FFXGlow combine. - return vec4(rgb, 1.0); + out.color = vec4(rgb, 1.0); + // The depth push (the reference's compressed far-band depth range). `view_z_to_depth_ndc` takes a + // view-space z — negative in front of the camera — so the wall is at `-farclip`. + var depth = in.clip_position.z; + if (farclip > 0.0) { + depth = min(depth, view_z_to_depth_ndc(-farclip) * WDL_DEPTH_PUSH); + } + out.depth = depth; + return out; } diff --git a/crates/benilla/assets/shaders/wow_particle.wgsl b/crates/benilla/assets/shaders/wow_particle.wgsl index 10f9226c..ce521592 100644 --- a/crates/benilla/assets/shaders/wow_particle.wgsl +++ b/crates/benilla/assets/shaders/wow_particle.wgsl @@ -58,6 +58,24 @@ const RAIN_FOG_GREY: vec3 = vec3(0.50196078, 0.50196078, 0.50196078); @fragment fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> FragmentOutput { + // HARD FAR-CLIP WALL (faithful `farclip` ~777 yd) — see terrain.wgsl for the law. The reference + // has ONE projection far plane and it clips the whole detailed world: terrain, models, liquid AND + // the effect family, which is drawn by the same scene pass through the same matrix. benilla + // emulates that plane per-pixel (our projection far sits at ~3000 yd so the coarse WDL horizon can + // draw behind the wall) — and every world shader took the emulation except this one. That gap IS + // bug B39: campfires, braziers and portal effects kept drawing at any distance, over a horizon + // where the terrain beneath them had already been discarded by the very same wall. + // + // Ribbons ride this shader too (`ribbons.rs` shares `WowParticleMaterial`), so trails are walled + // by the same discard. `fog_params.w` = farclip (0 ⇒ disabled). No pop: the scene fog end is + // `min(fog_end, farclip)`, so a quad reaching the wall has already faded to the fog colour — + // toward BLACK for an Add-blend emitter (`wow_ext_params.x`), i.e. to nothing under `One` blend. + if (wow_light.fog_params.w > 0.0) { + let clip_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z; + if (clip_z > wow_light.fog_params.w) { + discard; + } + } var pbr_input = pbr_input_from_standard_material(in, is_front); // base_color = white base × tex (gamma bytes, Unorm) × vertex colour (raw authored track // values) — the gamma-space product, exactly what the reference multiplies. diff --git a/crates/benilla/assets/ui/BagFrame.xml b/crates/benilla/assets/ui/BagFrame.xml index 090df770..aa7f5b2f 100644 --- a/crates/benilla/assets/ui/BagFrame.xml +++ b/crates/benilla/assets/ui/BagFrame.xml @@ -699,6 +699,12 @@ end end + -- The bag BAR's five buttons (the backpack toggle + the four equipped-bag slots) — the row + -- Disable_/Enable_BagButtons greys as a unit. Distinct from UiPanels.xml's BAG_WINDOWS, + -- which is the five bag *windows* those buttons open. + BENILLA_BAG_BUTTONS = { "BenillaBagToggle", "BenillaBagBarSlot1", "BenillaBagBarSlot2", + "BenillaBagBarSlot3", "BenillaBagBarSlot4" } + -- Disable_BagButtons / Enable_BagButtons — ref MainMenuBarBagButtons.lua l.102-128, called -- by GameMenuFrame's OnShow/OnHide (decision 0674) and by nothing else: while the game menu -- holds the screen, the whole bag row greys out. That is the *visible* half of a rule the @@ -706,29 +712,34 @@ -- while the menu is up — UiPanels.xml `CanOpenPanels`); without it the bags would look -- clickable and simply do nothing. -- - -- Mapping onto benilla's row: the ref's MainMenuBarBackpackButton is BenillaBagToggle — - -- whose icon IS its NormalTexture (it carries no named $parentIcon region, so the greying - -- goes through GetNormalTexture) — and CharacterBag0..3Slot are BenillaBagBarSlot1..4, each - -- with the $parentIcon the Update path above also writes. + -- Mapping onto benilla's row: the ref's MainMenuBarBackpackButton is BenillaBagToggle and + -- CharacterBag0..3Slot are BenillaBagBarSlot1..4 — and in BOTH cases the greying goes onto + -- the button's `$parentIcon` LAYER texture, never onto a state texture. That is the + -- reference's own structure and it is load-bearing here: a disabled button draws its + -- DisabledTexture, and with none set it draws no state texture at all + -- (`ButtonState::region_visible`), so greying a NormalTexture would grey something already + -- gone. The four slots' UI-Quickslot2 rings ARE state textures and do vanish while + -- disabled unless the slot states one explicitly — so what you should see is the + -- ring stay put and the bag icon grey — which is why each slot repeats its ring as + -- DisabledTexture (see the slot instances below). function Disable_BagButtons() - BenillaBagToggle:Disable() - SetDesaturation(BenillaBagToggle:GetNormalTexture(), 1) - for i = 1, 4 do - local slot = getglobal("BenillaBagBarSlot" .. i) - slot:Disable() - SetDesaturation(slot.icon, 1) + for _, button in ipairs(BENILLA_BAG_BUTTONS) do + local b = getglobal(button) + b:Disable() + SetDesaturation(b.icon, 1) end end function Enable_BagButtons() - BenillaBagToggle:Enable() - SetDesaturation(BenillaBagToggle:GetNormalTexture(), nil) + for _, button in ipairs(BENILLA_BAG_BUTTONS) do + local b = getglobal(button) + b:Enable() + SetDesaturation(b.icon, nil) + end + -- The four slots' own paint decides their restored colour: an item locked on the cursor + -- wants 0.4 grey, not the flat white SetDesaturation(nil) just left. for i = 1, 4 do - local slot = getglobal("BenillaBagBarSlot" .. i) - slot:Enable() - -- The slot's own paint decides the restored colour: an item locked on the cursor - -- wants 0.4 grey, not the flat white a bare SetDesaturation(nil) would leave. - BenillaBagBarSlot_Update(slot) + BenillaBagBarSlot_Update(getglobal("BenillaBagBarSlot" .. i)) end end ]]> @@ -1234,7 +1245,20 @@ - + + + + + + @@ -1242,7 +1266,10 @@ - if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(self) end + + self.icon = getglobal("BenillaBagToggleIcon") + if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(self) end + BenillaBagToggle_OnClick() BenillaBagToggle_OnEnter(self) BenillaBagBarButton_OnLeave() @@ -1268,6 +1295,17 @@ + + + + + @@ -1294,6 +1332,17 @@ + + + + + @@ -1319,6 +1368,17 @@ + + + + + @@ -1344,6 +1404,17 @@ + + + + + diff --git a/crates/benilla/assets/ui/UiPanels.xml b/crates/benilla/assets/ui/UiPanels.xml index 81262bff..7d01f080 100644 --- a/crates/benilla/assets/ui/UiPanels.xml +++ b/crates/benilla/assets/ui/UiPanels.xml @@ -89,6 +89,12 @@ -- every non-center panel (`CanOpenPanels` below). That is the rule behind "you can't open -- anything with the ESC menu up". whileDead: dying is exactly when you want to log out. UIPanelWindows["GameMenuFrame"] = { area = "center", pushable = 0, whileDead = 1 } + -- The world map (decision 0203 phase 2) — the ref's own line verbatim (UIParent.lua l.30) + -- and the first **full** row. It was a plain toplevel until now, showing itself with a bare + -- `WorldMapFrame:Show()` and so bypassing this manager entirely: with the game menu up it + -- was the ONE window that still opened (director-reported). Routing its toggle through + -- ShowUIPanel is what subjects it to the same rules as everything else. + UIPanelWindows["WorldMapFrame"] = { area = "full", pushable = 0, whileDead = 1 } -- The real client stores `.left`/`.center`/`.doublewide`/`.fullscreen` as fields directly on -- the `UIParent` frame table (UIParent.lua l.789-885). benilla has no `UIParent` frame object @@ -96,7 +102,7 @@ -- table instead of squatting the `UIParent` global name; a future `CreateFrame("Frame", -- "UIParent")` then lands cleanly on an empty global rather than silently coexisting with our -- state. Every function below is defined in this same chunk, so they all close over it. - local slots = { left = nil, center = nil } + local slots = { left = nil, center = nil, fullscreen = nil } -- SetLeftFrame(frame) — UIParent.lua l.809-824. Hides the old left occupant, SetPoints + -- Shows the new one at the left slot's screen anchor (verified: TOPLEFT UIParent, 0, -104). @@ -167,6 +173,26 @@ return slots.center end + -- SetFullScreenFrame(frame) — UIParent.lua l.852-866. ONE line dropped: the reference also + -- does `UIParent:Hide()`/`:Show()` around the swap, hiding the whole game UI behind the + -- fullscreen frame. benilla has no UIParent frame (the note on `slots` above), and the only + -- full frame we ship — the world map — already paints its own full-screen blackout + -- (WorldMapFrame.xml), which is what that Hide buys the reference. + function SetFullScreenFrame(frame) + local oldFrame = slots.fullscreen + slots.fullscreen = frame + if oldFrame then + oldFrame:Hide() + end + if frame then + frame:Show() + end + end + + function GetFullScreenFrame() + return slots.fullscreen + end + -- CanOpenPanels() — UIParent.lua l.887-910. Its dead-player arms are commented out in the -- reference itself (both of them, with the note that the fear/out-of-control gate was -- "previously"); what survives is one rule: **a native-center frame open ⇒ nothing else @@ -209,6 +235,17 @@ return end + -- l.668-675: a full-screen frame open ignores every non-full open request (`force` + -- evicts it instead). The world map is the only `full` row, so this reads: nothing opens + -- behind the map. + if GetFullScreenFrame() and info.area ~= "full" then + if force then + SetFullScreenFrame(nil) + else + return + end + end + -- l.677-689: with a native-center frame open, only another center request gets through -- — `force` evicts it instead of refusing. Redundant with the CanOpenPanels gate above -- for every caller that doesn't force (the reference double-checks it too); kept because @@ -226,6 +263,14 @@ end end + -- l.692-696: full-screen frames just replace each other, and they take everything with + -- them on the way in. + if info.area == "full" then + CloseAllWindows() + SetFullScreenFrame(frame) + return + end + -- l.697-702: native-center frames just replace each other and take the screen — -- everything else closes first. `skipSetPoint` is the load-bearing argument: the menu -- keeps its own CENTER anchor instead of being dropped at the left-area seat. @@ -284,6 +329,12 @@ return end + -- l.759-763. + if frame == GetFullScreenFrame() then + SetFullScreenFrame(nil) + return + end + if frame == GetCenterFrame() then SetCenterFrame(nil) return @@ -311,9 +362,11 @@ function CloseWindows(ignoreCenter) local leftFrame = GetLeftFrame() local centerFrame = GetCenterFrame() - local found = leftFrame or centerFrame + local fullScreenFrame = GetFullScreenFrame() + local found = leftFrame or centerFrame or fullScreenFrame HideUIPanel(leftFrame) + HideUIPanel(fullScreenFrame) if centerFrame then local info = UIPanelWindows[centerFrame:GetName()] if not info or info.area ~= "center" or not ignoreCenter then @@ -497,7 +550,10 @@ -- l.628; ours routes through this chain since frames get no raw key events). local worldmap = getglobal("WorldMapFrame") if worldmap and worldmap:IsVisible() then - worldmap:Hide() + -- HideUIPanel, not a bare Hide: the map holds the full-screen slot (decision 0203's + -- `full` row, wired 0674), and hiding it behind the manager's back would leave the + -- slot pointing at an invisible frame — which then refuses every future panel. + HideUIPanel(worldmap) return end -- The open-letter toplevel (decision 0544): the ref registers OpenMailFrame in diff --git a/crates/benilla/assets/ui/WorldMapFrame.xml b/crates/benilla/assets/ui/WorldMapFrame.xml index d4b6ce8b..9a3176cc 100644 --- a/crates/benilla/assets/ui/WorldMapFrame.xml +++ b/crates/benilla/assets/ui/WorldMapFrame.xml @@ -33,11 +33,16 @@ NUM_WORLDMAP_DETAIL_TILES = 12; NUM_WORLDMAP_OVERLAYS = 0; + -- ref WorldMapFrame.lua l.509-515, verbatim. Through the PANEL MANAGER, not a bare + -- Show/Hide: the map is registered `area = "full"` (UiPanels.xml), which is what makes it + -- take the screen from the other windows on the way in and — the reason this changed — + -- what stops it opening from behind the game menu, the one window that still could + -- (director-reported, decision 0674). function ToggleWorldMap() if ( WorldMapFrame:IsVisible() ) then - WorldMapFrame:Hide(); + HideUIPanel(WorldMapFrame); else - WorldMapFrame:Show(); + ShowUIPanel(WorldMapFrame); end end @@ -454,7 +459,7 @@ - WorldMapFrame:Hide(); + HideUIPanel(WorldMapFrame); diff --git a/crates/benilla/src/area_trigger.rs b/crates/benilla/src/area_trigger.rs new file mode 100644 index 00000000..d903e61b --- /dev/null +++ b/crates/benilla/src/area_trigger.rs @@ -0,0 +1,227 @@ +//! Area triggers — the client's half of every portal, instance entrance and "explore here" quest. +//! +//! `AreaTrigger.dbc` is a table of invisible volumes. The client's *entire* job is geometry: notice +//! that the player has walked into one, and send `CMSG_AREATRIGGER` naming its id. The **server** +//! decides what the trigger means — a teleport (`areatrigger_teleport`: the Darnassus/Rut'theran +//! portals, every dungeon and raid entrance), a quest's explore objective +//! (`areatrigger_involvedrelation`), the inn's rested state, a battleground's entrance list. The +//! client never knows and never needs to. +//! +//! Until this module existed, benilla sent that opcode from nowhere: a portal did nothing, and an +//! instance entrance did nothing, because the server was never told we were standing in one (ledger +//! **B70**, **N02**). The teleport that answers it has worked since decision 0455. +//! +//! ## The law, from the reference +//! +//! **VERIFIED** against `0x5e2110` (the per-frame check) and `0x5e22d0` (containment), wow-5875-re +//! `object-layer/scratch/w2b1-decomp.c`; the geometry itself is +//! [`AreaTriggerRow::contains`](benilla_formats::AreaTriggerRow::contains). +//! +//! - The map's rows are the only candidates (`0x5e2080` narrows the map-sorted table to a +//! `[first, end)` window before any test). +//! - **One latch, and it is an *exit* latch.** The check holds the trigger it is currently inside; +//! while the player is still inside *that* volume it returns immediately and sends nothing. Only +//! once they have left does it scan for a new one — so standing in a portal fires it exactly once, +//! and overlapping volumes never fight. +//! - The **first** containing row in file order wins. +//! - A map change clears the latch. +//! +//! Two things the reference's shape buys us, worth stating because they are load-bearing: a trigger +//! cannot re-fire while you stand in it (a portal would otherwise re-teleport you every frame), and +//! the *destination* of a portal pair is authored to land outside the return trigger (Darnassus's +//! exit lands 13.9 yd from the entrance's 10-yd sphere), so a round trip is not a loop. +//! +//! The server re-checks our claim with 5 yd of slop and ignores it while taxi-flying +//! (`HandleAreaTriggerOpcode`, vmangos `Handlers/MiscHandler.cpp:622`), so a wrong id is refused +//! rather than obeyed. +//! +//! ## ⚠ Probing this with `.go` is not the same as walking in +//! +//! A GM teleport that drops you *inside* a volume sends this message in the same millisecond as the +//! teleport ack, and the server re-checks the claim against the position it has stored — which is +//! sometimes still the pre-teleport one. Measured: five such teleport-ins were obeyed and one +//! (Darnassus's trigger 527) was silently ignored, while drifting into the same trigger under +//! movement fired it and teleported 40 ms later. So an ignored `.go` probe is the harness racing +//! the server, not this check failing — and the latch, faithfully, does not retry (it latches on +//! the *send*; the client never learns whether the server obeyed). Step out and back in, or better, +//! park outside and move in. + +use benilla_assets::coords::bevy_to_wow; +use benilla_formats::AreaTriggerCatalog; +use bevy::prelude::*; + +use crate::assets::{AssetSet, LockRecover, WorldAssets}; +use crate::net::{ClientCommand, NetCommands}; +use crate::player::Player; +use crate::schedule::WorldStage; +use crate::world_map::CurrentMap; + +/// The `AreaTrigger.dbc` catalog, bucketed by map. Absent when the client data didn't load — the +/// check then does nothing, like every other data-driven system here. +#[derive(Resource)] +pub(crate) struct AreaTriggers(pub(crate) AreaTriggerCatalog); + +/// The trigger we are currently standing **inside**, with the map it is on — the reference's single +/// "current trigger" pointer (`DAT_00c4d73c`). +/// +/// Keyed by map so it can never leak across a worldport (the reference clears it on the map change; +/// carrying the map id makes the same guarantee without a hook). It deliberately survives a +/// reconnect on the same map, which is also what the reference does — its clear runs off the map +/// change, not the socket — and it is the safer half of that coin: reconnecting inside a portal +/// does not teleport you. +#[derive(Resource, Default)] +pub(crate) struct InsideTrigger(Option<(u32, u32)>); + +impl InsideTrigger { + /// One check, as the reference orders it (`0x5e2110`): if we are still inside the latched + /// volume, report nothing; otherwise unlatch, and latch + report the **first** trigger on this + /// map containing `p`, if any. `Some(id)` means "send `CMSG_AREATRIGGER` for this one". + /// + /// Pure, and separate from the system, because the property that matters is a state machine + /// rather than a query: it must fire **once** per entry. Firing per frame would be a packet + /// flood at best and, on a teleport trigger, an infinite loop — arrive, fire, arrive. + fn step(&mut self, triggers: &AreaTriggerCatalog, map_id: u32, p: [f32; 3]) -> Option { + if let Some((latched_map, id)) = self.0 { + let still_in = latched_map == map_id + && triggers + .on_map(map_id) + .iter() + .find(|t| t.id == id) + .is_some_and(|t| t.contains(p)); + if still_in { + return None; + } + self.0 = None; + } + let entered = triggers.first_containing(map_id, p)?; + self.0 = Some((map_id, entered.id)); + Some(entered.id) + } +} + +pub(crate) struct AreaTriggerPlugin; + +impl Plugin for AreaTriggerPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Startup, load_area_triggers.after(AssetSet::Open)) + // In the Stream band: after Input has moved the avatar (and applied any teleport + // snap), so the position tested is the one this frame ends at — the same one the + // movement stream reports, which is what the server re-checks us against. + .add_systems( + Update, + check_area_triggers + .in_set(WorldStage::Stream) + .run_if(in_state(crate::char_select::ClientState::InWorld)), + ); + } +} + +fn load_area_triggers(mut commands: Commands, assets: Option>) { + let Some(assets) = assets else { return }; + let mut chain = assets.chain.lock_recover(); + match benilla_formats::load_area_trigger_catalog(&mut chain) { + Ok(cat) => { + info!("area_trigger: {} triggers", cat.len()); + commands.insert_resource(AreaTriggers(cat)); + } + Err(e) => warn!("area_trigger: AreaTrigger.dbc failed to load: {e:#}"), + } +} + +/// The per-frame check — the reference's `0x5e2110`, in the same order it runs. +fn check_area_triggers( + triggers: Option>, + map: Option>, + player: Res, + mut inside: ResMut, + net: Res, +) { + let (Some(triggers), Some(map)) = (triggers, map) else { + return; + }; + // Before the server has placed us, `pos` is wherever the free-flying camera left the avatar — + // not a position the server would recognise, and not one worth reporting. + if !player.active { + return; + } + let here = bevy_to_wow(player.pos); + let Some(trigger_id) = inside.step(&triggers.0, map.0, here) else { + return; + }; + // Fire-and-forget, like every other send: a down write thread drops it. + let _ = net.0.send(ClientCommand::AreaTrigger { trigger_id }); + // At `info`, deliberately: entering a trigger is rare (you must cross a volume boundary), and + // when a portal or instance entrance "does nothing", the first question is whether the client + // saw the volume at all. One line answers it. + info!( + "area_trigger: entered {trigger_id} on map {} at [{:.2}, {:.2}, {:.2}]", + map.0, here[0], here[1], here[2] + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn real_catalog() -> Option { + 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 None; + } + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + Some(benilla_formats::load_area_trigger_catalog(&mut chain).expect("AreaTrigger.dbc")) + } + + /// The whole state machine over the **real** table, walking the exact route the live probe + /// walked: the Darnassus portal pair (B70) and the Southshore inn's box (the shape the sphere + /// tests can't reach). Skips without client data. + /// + /// The second assertion is the load-bearing one — a portal that reported every frame would + /// teleport you the instant you arrived, forever. + #[test] + fn a_trigger_fires_once_per_entry_and_re_arms_on_leaving() { + let Some(cat) = real_catalog() else { return }; + let mut inside = InsideTrigger::default(); + + // Rut'theran Village's portal (542, a 10-yd sphere): step in, and it reports once. + let ruttheran = [8799.41, 969.787, 30.2409]; + assert_eq!(inside.step(&cat, 1, ruttheran), Some(542)); + assert_eq!( + inside.step(&cat, 1, ruttheran), + None, + "standing in a portal must not re-report it — that is the teleport loop" + ); + + // The server answers by putting us at the Darnassus end. That landing spot is deliberately + // OUTSIDE the return trigger (527, also 10 yd) — 17.2 yd from its centre, and 13.9 yd the + // other direction — which is why a round trip is not a loop. Nothing fires, and the latch + // re-arms. + let darnassus_arrival = [9946.25, 2612.97, 1316.49]; + assert_eq!(inside.step(&cat, 1, darnassus_arrival), None); + + // Walk into the return portal proper: it reports, having re-armed. + assert_eq!(inside.step(&cat, 1, [9947.48, 2630.04, 1318.6]), Some(527)); + + // A map change cannot leak the latch: the Southshore inn's BOX trigger (708) is on map 0 + // and fires immediately, even though a trigger is still latched from map 1. + assert_eq!( + inside.step(&cat, 0, [-854.547, -576.314, 18.4659]), + Some(708) + ); + assert_eq!(inside.step(&cat, 0, [-854.593, -576.207, 18.5563]), None); + + // Deadmines' entrance (78) — the instance-portal case (N02), a 7-yd sphere on map 0. + assert_eq!(inside.step(&cat, 0, [-11208.5, 1685.34, 25.7612]), Some(78)); + } + + /// Empty air reports nothing, and an unknown map is not an error. + #[test] + fn open_ground_and_unknown_maps_are_silent() { + let Some(cat) = real_catalog() else { return }; + let mut inside = InsideTrigger::default(); + assert_eq!(inside.step(&cat, 0, [0.0, 0.0, 0.0]), None); + assert_eq!(inside.step(&cat, 9999, [8799.41, 969.787, 30.2409]), None); + } +} diff --git a/crates/benilla/src/billboard.rs b/crates/benilla/src/billboard.rs index 46fb7861..d4bfb060 100644 --- a/crates/benilla/src/billboard.rs +++ b/crates/benilla/src/billboard.rs @@ -23,6 +23,7 @@ use benilla_assets::BillboardInfo; use benilla_formats::{BillboardKind, BoneScaleAnim}; +use bevy::mesh::MeshTag; use bevy::prelude::*; use crate::player::WorldCamera; @@ -32,8 +33,15 @@ use crate::player::WorldCamera; /// per-frame system rewrites the entity transform from these — including `Visibility` (the /// hidden-owner mirror), so a card requires it rather than trusting every spawn site's `Mesh3d` /// to bring it along. +/// +/// It requires a [`MeshTag`] for the same reason: a card is a world ROOT, so every per-model alpha +/// that reaches an ordinary submesh by descending the model's tree has to reach a card through its +/// own tag instead (`player::apply_self_model_fade` — the zoom-to-first-person feather). Only the +/// alpha-animated spawn sites used to bring one, which left the channel *incidentally* present; +/// the default `MeshTag(0)` is the shader's untagged-⇒-opaque sentinel, so requiring it changes +/// nothing about how a card draws. #[derive(Component)] -#[require(Transform, Visibility)] +#[require(Transform, Visibility, MeshTag)] pub struct BillboardCard { world_pivot: Vec3, scale: f32, @@ -124,6 +132,14 @@ impl BillboardCard { self.seq_translation = anim; } + /// The entity this card follows, if any — the anchor/joint that decides both where it sits and + /// which model it BELONGS to. A card is a world root, so a system that walks a model's tree + /// (the self-avatar fade) can only recognise the model's own cards by testing this against the + /// entities it walked. + pub(crate) fn follows(&self) -> Option { + self.follow + } + /// Re-seat a card that FOLLOWS something (the questgiver `!`/`?` markers over a unit that can /// move) — recompute the world pivot/scale/rotation from a fresh `placement`, keeping the card's /// orientation kind, rest normal, and animation phase. Doodad cards never need this (their diff --git a/crates/benilla/src/capture/mod.rs b/crates/benilla/src/capture/mod.rs index 7018d006..f6bfed61 100644 --- a/crates/benilla/src/capture/mod.rs +++ b/crates/benilla/src/capture/mod.rs @@ -141,6 +141,11 @@ pub(crate) struct FxViewState { /// the ground scenario's streamed tiles, high enough that terrain never intersects the model. pub(crate) const FXVIEW_POS: [f32; 3] = [-8960.0, -145.0, 90.0]; +/// How far the `vista` fixture seats its eye above the position it is given (yd) — a standing human's +/// camera pivot, so pasting a `.go xyz` straight off the debug panel frames what the director saw +/// rather than a worm's-eye view from inside the ground. +const VISTA_EYE_HEIGHT: f32 = 2.0; + /// Print the BASELINE scenario names, one per line — the single source of truth /// `scripts/visual.sh` reads so the driver never drifts from the code. Invoked by `main` for /// `WOW_CAPTURE=list`. On-demand fixtures (the UI look-pass windows, sun/moon/sky, house-compass) @@ -298,6 +303,51 @@ impl Plugin for CapturePlugin { minute: 720, ui: None, } + } else if name == "vista" { + // The **arbitrary-viewpoint** instrument: stand anywhere on the map, face any heading, + // at any clock — the world half of what `fxview` is for effects. A director report that + // arrives as "look at this horizon, here" (position, facing and time are all on the debug + // panel, and `copy .go xyz` puts the position on the clipboard) becomes a reproducible + // headless capture instead of a round-trip. Pair it with `WOW_FARCLIP` to match their + // slider — horizon and fog artifacts live and die by the far-clip wall. Not a golden + // scenario (its output depends on the knobs, not the name). + // + // WOW_CAPTURE=vista WOW_VISTA_AT=-5841.9,-3802.4,-59.7 WOW_VISTA_FACE=24 \ + // WOW_VISTA_MIN=1052 WOW_FARCLIP=320 WOW_CAPTURE_OUT=/tmp/v.png cargo run -q -p benilla + // + // Knobs: `WOW_VISTA_AT` (required, raw WoW `x,y,z` — the PLAYER position; the eye seats + // `VISTA_EYE_HEIGHT` above it), `WOW_VISTA_FACE` (heading in degrees — the panel's + // "facing" in its `(24°)` form; 0 = +X, counter-clockwise), `WOW_VISTA_PITCH` (degrees, + // + = up, default 0 = level), `WOW_VISTA_MIN` (game minute of day, default 720 = noon). + let knob = |k: &str, d: f32| { + std::env::var(k) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(d) + }; + let Some(at) = std::env::var("WOW_VISTA_AT").ok().and_then(|v| { + let c: Vec = v.split(',').filter_map(|p| p.trim().parse().ok()).collect(); + (c.len() == 3).then(|| [c[0], c[1], c[2]]) + }) else { + eprintln!("WOW_CAPTURE=vista needs WOW_VISTA_AT= (raw WoW coords)"); + std::process::exit(2); + }; + let face = knob("WOW_VISTA_FACE", 0.0).to_radians(); + let pitch = knob("WOW_VISTA_PITCH", 0.0).to_radians(); + let eye = [at[0], at[1], at[2] + VISTA_EYE_HEIGHT]; + // A look point far enough out that the framing is the heading, not the distance. + let d = 500.0_f32; + Scenario { + name: "vista", + eye, + look: [ + eye[0] + d * pitch.cos() * face.cos(), + eye[1] + d * pitch.cos() * face.sin(), + eye[2] + d * pitch.sin(), + ], + minute: knob("WOW_VISTA_MIN", 720.0) as u32, + ui: None, + } // By name, EITHER table: the blessed six or an on-demand fixture. Only the sweep is // narrowed — every old viewpoint is still capturable by name (decision 0632). } else if let Some(&s) = SCENARIOS diff --git a/crates/benilla/src/capture/probes.rs b/crates/benilla/src/capture/probes.rs index 6f1a7c41..c540af39 100644 --- a/crates/benilla/src/capture/probes.rs +++ b/crates/benilla/src/capture/probes.rs @@ -661,6 +661,12 @@ fn drive_live_fps( /// login whirlpool investigation: the real client draws 793 particle quads across 23 draws in /// one `UI_MainMenu` frame). Works at any state — the glue screens included, unlike the /// in-world-gated FPS probe. +/// +/// It also measures **draw distance** (decision 0678): each emitter's planar depth along +/// camera-forward — the coordinate the far-clip wall uses — and the draw-set gate's verdict, with +/// `drawn_beyond_wall` on the summary line. That is the numeric form of "effects render at +/// unlimited distance" (bug B39): emitters still ticking and drawing past the wall that has already +/// discarded the terrain beneath them. **It must read 0**; a non-zero value is the bug, live. pub(crate) struct ParticleCensusPlugin; impl Plugin for ParticleCensusPlugin { @@ -684,7 +690,14 @@ struct ParticleCensus { fn fire_particle_census( mut probe: ResMut, time: Res