diff --git a/crates/benilla-adt/src/lib.rs b/crates/benilla-adt/src/lib.rs index afdcc663..854f507e 100644 --- a/crates/benilla-adt/src/lib.rs +++ b/crates/benilla-adt/src/lib.rs @@ -21,31 +21,6 @@ use std::io::Cursor; use benilla_bytes::{chunks, ByteExt}; -/// Liquid surface type, from the MCNK flags. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LiquidType { - Water, - Ocean, - Magma, - Slime, -} - -impl LiquidType { - /// Match wow-adt: ocean (0x08) → magma (0x10) → slime (0x20) → water (the default, incl. 0x04 - /// river). - fn from_mcnk_flags(flags: u32) -> Self { - if flags & 0x08 != 0 { - Self::Ocean - } else if flags & 0x10 != 0 { - Self::Magma - } else if flags & 0x20 != 0 { - Self::Slime - } else { - Self::Water - } - } -} - /// A terrain vertex normal — stored on disk as `x, z, y` signed bytes. #[derive(Debug, Clone, Copy)] pub struct VertexNormal { @@ -130,6 +105,10 @@ impl McshChunk { } /// One MCLQ liquid vertex (8 bytes: 4 union bytes + an absolute height). +/// +/// The leading 4 bytes are a **union whose meaning is the block's liquid type** — water/ocean spend +/// them on a depth byte + flow/foam, magma/slime on a `u16` texture-coordinate pair. Both readers are +/// below; which one applies is the caller's decision, from the cell nibble. #[derive(Debug, Clone, Copy)] pub struct LiquidVertex { pub union_data: [u8; 4], @@ -137,20 +116,50 @@ pub struct LiquidVertex { } impl LiquidVertex { - /// The depth byte (union byte 0) — drives the water depth-swatch on the render side. + /// The depth byte (union byte 0) — drives the water depth-swatch on the render side. Water and + /// ocean blocks only; on a magma block these bytes are [`Self::texcoords`] instead. pub fn depth_byte(&self) -> u8 { self.union_data[0] } + + /// The authored `(s, t)` texture-coordinate pair — two little-endian `u16`s over the same 4 + /// union bytes. **Magma blocks only**: the reference's lava vert-fill is single-stage and reads + /// its `tc0` straight from here, where water/ocean instead write a UV into the depth-ramp + /// texture (VERIFIED bit-exact `WoW.exe 0x68d890` `liquid_render_verts`, emulation-diffed in + /// wow-re `crates/terrain`: `u = (s as i32 as f64 · 3/256) as f32`, same for `t`). The field is + /// authored world-continuous — a chunk's east edge repeats its neighbour's west edge exactly — + /// so lava tiles seamlessly across MCNK borders. + pub fn texcoords(&self) -> [u16; 2] { + [ + u16::from_le_bytes([self.union_data[0], self.union_data[1]]), + u16::from_le_bytes([self.union_data[2], self.union_data[3]]), + ] + } } -/// MCLQ — legacy per-MCNK liquid (9×9 height grid + 8×8 cell flags). +/// One MCLQ liquid **block** — a 9×9 absolute-height grid + an 8×8 cell-flag grid, `0x324` bytes. +/// +/// An MCNK carries **one block per set liquid header bit** (bits 2–5), packed back to back and with +/// absent bits consuming nothing (VERIFIED wow-re `system/terrain/scratch/adt-format.md`, the cursor +/// walk at `0x6af7a3`–`0x6af7cb`). Most liquid chunks set exactly one bit; **28 shipped Azeroth +/// chunks set two** — a river *and* the sea, at a river mouth — and carry two blocks at different +/// heights (measured: `sizeMCLQ` 1616 = 8 + 2·`0x324`, block 0 the stream at z≈5.0, block 1 the +/// ocean at z=0). Reading only the first is how the sea goes missing there. +/// +/// **No liquid type here on purpose.** The header-bit→type ordering (bit2 river / bit3 ocean / +/// bit4 magma / bit5 slime) is *INFERRED* upstream — never byte-proven — while the per-cell flag low +/// nibble IS the type, VERIFIED at both consumers (`0x6ba970` `and al,0xf`, `0x68d9b0`). So this +/// reader takes only the block *count* from the flags and leaves the type to whoever reads +/// [`Self::tile_flags`]. (The old `LiquidType::from_mcnk_flags` priority guess labelled all 28 +/// two-bit chunks "ocean" while handing back the river block's bytes.) #[derive(Debug, Clone)] pub struct MclqChunk { pub min_height: f32, pub max_height: f32, pub vertices: Vec, + /// Per-cell flags, row-major 8×8. Low nibble = liquid type (`0xf` = dry/hole); `0x40` fishable, + /// `0x80` shared. pub tile_flags: [u8; 64], - pub liquid_type: LiquidType, } /// The fields of the 128-byte MCNK header the renderer reads. `pred_tex`/`no_effect_doodad`/ @@ -180,7 +189,9 @@ pub struct McnkChunk { pub layers: Option, pub alpha: Option, pub shadow: Option, - pub liquid: Option, + /// The chunk's MCLQ liquid blocks — **one per set liquid header bit**, in on-disk order (see + /// [`MclqChunk`]). Empty on a dry chunk; two at a river mouth. + pub liquids: Vec, } /// MDDF — a placed M2 doodad (36 bytes). @@ -408,7 +419,7 @@ fn read_mcnk(data: &[u8]) -> Result { layers: None, alpha: None, shadow: None, - liquid: None, + liquids: Vec::new(), }; // Locate sub-chunks by the MCNK header offsets (the authoritative method — sequential scanning @@ -515,15 +526,41 @@ fn read_mcnk(data: &[u8]) -> Result { b"QLCM", ) { let size_liquid = h.u32_at(0x64).ok_or(Error::Truncated("MCNK header"))? as usize; - chunk.liquid = read_mclq(slice(p, size_liquid), chunk.header.flags); + chunk.liquids = read_mclq_blocks(slice(p, size_liquid), chunk.header.flags); } Ok(chunk) } +/// Bytes per MCLQ block: `{f32 min, f32 max, 81×8B verts, 64B cell flags, u32 nFlow, 2×40B flow}`. +const MCLQ_BLOCK: usize = 0x324; +/// Liquid header bits (2–5) — their **count** is the number of packed MCLQ blocks. Only the count is +/// read: the bit→type ordering is INFERRED, the cell nibble is VERIFIED (see [`MclqChunk`]). +const MCNK_LIQUID_BITS: u32 = 0x3c; + +/// Read the packed MCLQ blocks — one per set liquid header bit, back to back (VERIFIED wow-re +/// `adt-format.md`). Stops early on a block the payload can't cover, so a truncated tail degrades to +/// the blocks that *are* whole rather than to nothing. A chunk with an MCLQ sub-chunk but no liquid +/// bit set still yields one block, matching the pre-multi-block reader. +fn read_mclq_blocks(sub: &[u8], mcnk_flags: u32) -> Vec { + let want = (mcnk_flags & MCNK_LIQUID_BITS).count_ones().max(1) as usize; + let mut out = Vec::with_capacity(want); + for i in 0..want { + let Some(rest) = sub.get(i * MCLQ_BLOCK..) else { + break; + }; + match read_mclq_block(rest) { + Some(b) => out.push(b), + None => break, + } + } + out +} + /// `None` (not an error) below the minimum size: too-short MCLQ data is treated as "no liquid", -/// matching the pre-0064 reader — a real-data tolerance, not a hardening carve-out. -fn read_mclq(sub: &[u8], mcnk_flags: u32) -> Option { +/// matching the pre-0064 reader — a real-data tolerance, not a hardening carve-out. (The minimum is +/// the *read* span, short of the full [`MCLQ_BLOCK`]: the trailing flow vectors are unread.) +fn read_mclq_block(sub: &[u8]) -> Option { const NEED: usize = 8 + 81 * 8 + 64; if sub.len() < NEED { return None; @@ -544,7 +581,6 @@ fn read_mclq(sub: &[u8], mcnk_flags: u32) -> Option { max_height, vertices, tile_flags, - liquid_type: LiquidType::from_mcnk_flags(mcnk_flags), }) } @@ -663,6 +699,60 @@ mod tests { data.extend(chunk(b"QLCM", &[0u8; 4])); let b = chunk(b"KNCM", &data); let ParsedAdt::Root(root) = parse(&b).expect("parses"); - assert!(root.mcnk_chunks[0].liquid.is_none()); + assert!(root.mcnk_chunks[0].liquids.is_empty()); + } + + /// **One MCLQ block per set liquid header bit**, packed back to back — the shape that made the + /// sea vanish at 28 shipped river mouths when only the first was read (see [`MclqChunk`]). + /// Two bits set ⇒ two blocks, each keeping its OWN cell flags, so the consumer can tell the + /// river block from the ocean block by nibble. + #[test] + fn a_two_bit_mcnk_yields_two_mclq_blocks_in_disk_order() { + // One block: min/max, 81×8B verts, 64B cell flags, then the flow tail — 0x324 total. + let block = |height: f32, nibble: u8| { + let mut b = vec![0u8; MCLQ_BLOCK]; + b[0..4].copy_from_slice(&height.to_le_bytes()); + b[4..8].copy_from_slice(&height.to_le_bytes()); + for i in 0..81 { + b[8 + i * 8 + 4..8 + i * 8 + 8].copy_from_slice(&height.to_le_bytes()); + } + b[8 + 81 * 8..8 + 81 * 8 + 64].fill(nibble); + b + }; + let mut payload = block(5.0, 4); // bit 2 (river) — first on disk + payload.extend(block(0.0, 1)); // bit 3 (ocean) — second + + let mut data = vec![0u8; 128]; + data[0x00..0x04].copy_from_slice(&0x0cu32.to_le_bytes()); // liquid bits 2 AND 3 + data[0x60..0x64].copy_from_slice(&128u32.to_le_bytes()); + data[0x64..0x68].copy_from_slice(&((payload.len() + 8) as u32).to_le_bytes()); + data.extend(chunk(b"QLCM", &payload)); + let b = chunk(b"KNCM", &data); + let ParsedAdt::Root(root) = parse(&b).expect("parses"); + + let liquids = &root.mcnk_chunks[0].liquids; + assert_eq!(liquids.len(), 2, "one block per set liquid bit"); + assert_eq!(liquids[0].tile_flags[0] & 0xf, 4, "block 0 is the river"); + assert_eq!(liquids[0].min_height, 5.0); + assert_eq!(liquids[1].tile_flags[0] & 0xf, 1, "block 1 is the ocean"); + assert_eq!(liquids[1].min_height, 0.0); + } + + /// The magma union bytes are an authored `(s, t)` u16 pair, little-endian over the same 4 bytes + /// the water blocks spend on a depth byte — VERIFIED `0x68d890` reads them as `u16 → i32`, so + /// `0xffff` is 65535 and never −1. + #[test] + fn magma_union_bytes_read_as_two_unsigned_u16_texcoords() { + let v = LiquidVertex { + union_data: [0xbd, 0x00, 0xe8, 0x00], + height: 1.0, + }; + assert_eq!(v.texcoords(), [189, 232]); // a real Burning Steppes vertex + assert_eq!(v.depth_byte(), 0xbd, "the same bytes, read the water way"); + let max = LiquidVertex { + union_data: [0xff, 0xff, 0xff, 0xff], + height: 1.0, + }; + assert_eq!(max.texcoords(), [65535, 65535], "unsigned, not −1"); } } diff --git a/crates/benilla-assets/examples/face_facing_at.rs b/crates/benilla-assets/examples/face_facing_at.rs new file mode 100644 index 00000000..e01d463e --- /dev/null +++ b/crates/benilla-assets/examples/face_facing_at.rs @@ -0,0 +1,186 @@ +//! Which way do the collidable faces under a world pin actually FACE — and would the reference +//! block on them? +//! `cargo run -p benilla-assets --example face_facing_at -- [wmo-substring]` +//! e.g. `face_facing_at Kalimdor -8137.9 -4897.2 2.0 caverns`. +//! +//! The reference's movement collision is **one-sided** (decision 0967, wow-re +//! `collision/scratch/wmo-movement-group-gate.md`): `0x671cc0` emits each candidate face's plane at +//! the **unflipped file winding**, and `0x632700` then processes it only if `n·dir <= -1e-5`. A +//! player falling straight down has `dir` = −up, so **a face blocks the fall iff its authored normal +//! points up**. Ours is a parry trimesh — two-sided — so it blocks either way. +//! +//! This tool turns that difference into a number at a specific `.go xyz` pin: every walking-collidable +//! WMO face whose footprint contains the pin, with its world height, its authored normal's up +//! component, and the reference's verdict. Where a face reads `PASSES`, the reference falls through +//! it and we stand on it. +//! +//! It goes through the **same transform the app bakes colliders with** — `wow_to_bevy` per vertex +//! then the placement `Transform`, exactly as `terrain_stream::collider::placement_collider_data` +//! does — so the geometry here is the geometry the player collides with, not a re-derivation. Both +//! maps are proper rotations, so a face's winding (and the sign of `n·dir`) survives them. +//! +//! Output is Blizzard data — pipe it to the scratchpad, never into the repo. + +use benilla_assets::coords::{placement_rotation, wow_to_bevy}; +use bevy::prelude::*; + +/// The reference's facing gate: `[0x80c5c4] = 0xb727c5ac`. A face is processed iff `n·dir <= EPS`. +const FACING_EPS: f32 = f32::from_bits(0xb727_c5ac); + +fn main() -> anyhow::Result<()> { + let mut args = std::env::args().skip(1); + let usage = || anyhow::anyhow!("usage: face_facing_at [wmo-substring]"); + let map = args.next().ok_or_else(usage)?; + let x: f32 = args.next().ok_or_else(usage)?.parse()?; + let y: f32 = args.next().ok_or_else(usage)?.parse()?; + let z: f32 = args.next().ok_or_else(usage)?.parse()?; + let filter = args.next().unwrap_or_default().to_lowercase(); + + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + let mut chain = benilla_formats::open_chain(&data)?; + let tiles = benilla_formats::MapTiles::load(&mut chain, &map)?; + let pin = wow_to_bevy([x, y, z]); + // Falling: WoW −Z, which `wow_to_bevy` carries to Bevy −Y. + let dir = Vec3::NEG_Y; + println!("{map}: pin ({x}, {y}, {z}) — faces under a straight-down approach\n"); + + // Every placement in the 3x3 tile neighbourhood, deduped by uniqueId (a building spans tiles). + let mut seen = std::collections::HashSet::new(); + let mut placements: Vec<(String, Transform, u32)> = Vec::new(); + for (tx, ty) in tiles.existing_in_radius(x, y, 1) { + let tile = benilla_formats::load_tile_mesh(&mut chain, &map, tx, ty)?; + for w in &tile.wmos { + if !filter.is_empty() && !w.model.to_lowercase().contains(&filter) { + continue; + } + if seen.insert(w.unique_id) { + placements.push(( + w.model.clone(), + Transform { + translation: wow_to_bevy(w.position), + rotation: placement_rotation(w.rotation), + scale: Vec3::ONE, + }, + w.unique_id, + )); + } + } + } + + let mut hits: Vec = Vec::new(); + for (model, transform, uid) in &placements { + let stem = model.to_ascii_lowercase().replace('\\', "/"); + let stem = stem.strip_suffix(".wmo").unwrap_or(&stem).to_string(); + // Walk group files by index — the same `_NNN.wmo` naming the loader uses. A gap is not + // expected, but tolerate a few misses rather than trusting a count we did not read. + let (mut gi, mut misses) = (0u32, 0u32); + while misses < 4 { + let Ok(gbytes) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else { + misses += 1; + gi += 1; + continue; + }; + misses = 0; + // The WALKING gather — the player-body audience, skip DETAIL (`0x04`). Identical call to + // the one the WMO asset loader makes. + let (mut pos, mut idx) = (Vec::new(), Vec::new()); + benilla_formats::accumulate_wmo_group_collision(&gbytes, &mut pos, &mut idx); + let verts: Vec = pos + .iter() + .map(|p| transform.transform_point(wow_to_bevy(*p))) + .collect(); + for t in idx.chunks_exact(3) { + let (Some(&a), Some(&b), Some(&c)) = ( + verts.get(t[0] as usize), + verts.get(t[1] as usize), + verts.get(t[2] as usize), + ) else { + continue; + }; + let Some(height) = plane_height_under(pin, [a, b, c]) else { + continue; + }; + let n = (b - a).cross(c - a); + if n.length_squared() < 1e-18 { + continue; + } + hits.push(Hit { + height, + dot: n.normalize().dot(dir), + group: gi, + uid: *uid, + model: model.clone(), + }); + } + gi += 1; + } + } + + if hits.is_empty() { + println!("no walking-collidable WMO face has this pin in its footprint"); + return Ok(()); + } + hits.sort_by(|p, q| { + (p.height - pin.y) + .abs() + .total_cmp(&(q.height - pin.y).abs()) + }); + println!( + "{:>9} {:>9} {:>9} {:>8} {:>4} wmo", + "worldZ", "Δ pin", "n·dir", "ref", "grp" + ); + let (mut blocks, mut passes) = (0usize, 0usize); + for h in &hits { + // The pin is Bevy-space; its Y is the WoW Z the tester read off the panel. + let reference_blocks = h.dot <= FACING_EPS; + if reference_blocks { + blocks += 1; + } else { + passes += 1; + } + println!( + "{:>9.2} {:>9.2} {:>9.4} {:>8} {:>4} #{} {}", + h.height, + h.height - pin.y, + h.dot, + if reference_blocks { "BLOCKS" } else { "PASSES" }, + h.group, + h.uid, + h.model, + ); + } + println!( + "\n{} face(s) under the pin: reference blocks on {blocks}, falls through {passes}. \ + We block on all {} (parry trimesh is two-sided).", + hits.len(), + hits.len() + ); + Ok(()) +} + +struct Hit { + /// World height (Bevy +Y = WoW +Z) where the pin's vertical line meets this face's plane. + height: f32, + /// The authored winding normal dotted with the fall direction — the reference's gate input. + dot: f32, + group: u32, + uid: u32, + model: String, +} + +/// Where the vertical line through `pin` meets the triangle's plane — `None` unless the pin's +/// horizontal footprint is inside the triangle (the XZ projection in Bevy space, since +Y is up). +fn plane_height_under(pin: Vec3, tri: [Vec3; 3]) -> Option { + let p = Vec2::new(pin.x, pin.z); + let [a, b, c] = tri.map(|v| Vec2::new(v.x, v.z)); + let area = (b - a).perp_dot(c - a); + if area.abs() < 1e-9 { + return None; // edge-on to the vertical: no footprint to stand in + } + let (u, v) = ( + (b - p).perp_dot(c - p) / area, + (c - p).perp_dot(a - p) / area, + ); + let w = 1.0 - u - v; + (u >= 0.0 && v >= 0.0 && w >= 0.0).then(|| u * tri[0].y + v * tri[1].y + w * tri[2].y) +} diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index 9ac6f78b..93ef0288 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -330,6 +330,7 @@ impl AssetLoader for M2ModelLoader { sidn: None, // WMO-only (MOMT SIDN night glow) window: false, // WMO-only (MOMT WINDOW interior light) additive: sub.additive, // M2 blend 3/4 → additive (glow cards) + env_map: sub.env_map, // texture_unit_lookup > 2 → the runtime generates this stage's UVs no_depth_write: sub.no_depth_write, // M2 render flag 0x10 no_depth_test: sub.no_depth_test, // M2 render flag 0x08 fog_policy: sub.fog_policy, // M2 render flag 0x02 / the per-blend fog table diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs index 8550d664..cc88f687 100644 --- a/crates/benilla-assets/src/model.rs +++ b/crates/benilla-assets/src/model.rs @@ -94,6 +94,12 @@ pub struct ModelSubmesh { /// [`benilla_formats::RenderSubmesh::additive`]): the spawn site builds it with `AlphaMode::Add` so /// its warm colour is added on top of the scene, not mixed with the background. `false` otherwise. pub additive: bool, + /// This batch's texture coordinates are **generated, not authored** (see + /// [`benilla_formats::RenderSubmesh::env_map`]): a sphere-map environment coordinate off the + /// view-space reflection vector. The spawn site marks the material so `wow_model.wgsl` derives + /// the UV instead of reading the (meaningless) vertex one. `false` for WMO batches and for the + /// M2 batches that name a real UV channel. + pub env_map: bool, /// M2 render flag **0x10 — disable depth write** (see [`benilla_formats::RenderSubmesh::no_depth_write`]): /// `specialize` keeps depth-write ON for transparent batches *unless* this is set, matching the real /// client (so a model's transparent cards occlude each other instead of bleeding through). `false` for WMO. diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs index 077e2aa8..7496ce60 100644 --- a/crates/benilla-assets/src/wmo.rs +++ b/crates/benilla-assets/src/wmo.rs @@ -139,8 +139,11 @@ pub struct WmoModel { pub doodad_base: Vec, /// Per-MODD **instantiating group** (the first group whose MODR refs name it), parallel to /// [`Self::doodads`]; `None` for a MODD no group references. The reference creates a doodad - /// once, on the first visible-group walk that reaches it, and that create is what freezes its - /// lighting lane — so this is the LIGHTING key ([`Self::doodad_base`]), not the cull key. + /// once, on the first visible-group walk that reaches it, and that create is what fills its + /// baked colour words — so this names the MOLR set the interior fold gates on + /// ([`Self::doodad_base`]). It is **not** the interior/exterior class: every referrer writes the + /// lane of the same def and exterior is absorbing, so the class reads [`Self::doodad_groups`] + /// (see [`resolve_doodad_bases`]). pub doodad_owner: Vec>, /// Per-MODD **referencing groups** — every group whose MODR names it, parallel to /// [`Self::doodads`]; empty for a MODD no group references. @@ -373,8 +376,9 @@ pub fn floor168(c: [u8; 3]) -> [f32; 3] { /// Invert MODR: MODD index → its **instantiating** group. First referencing group wins — the /// reference creates a doodad once, on the first visible-group walk that names it, and that create -/// is what freezes its lighting lane. `None` = referenced by no group at all (the reference never -/// instantiates such a MODD). This is the LIGHTING key only; the *cull* key is [`modr_refs`]. +/// is what fills its baked colour words. `None` = referenced by no group at all (the reference never +/// instantiates such a MODD). This picks the interior lane's **MOLR light set**; the interior/exterior +/// *class* is the whole referrer set's ([`resolve_doodad_bases`]), and the *cull* key is [`modr_refs`]. fn modr_owners(doodad_count: usize, group_doodad_refs: &[Vec]) -> Vec> { let mut owner: Vec> = vec![None; doodad_count]; for (gi, refs) in group_doodad_refs.iter().enumerate() { @@ -409,33 +413,51 @@ fn modr_refs(doodad_count: usize, group_doodad_refs: &[Vec]) -> Vec], + refs: &[Arc<[u16]>], group_light_refs: &[Vec], ) -> Vec { + let interior_group = |gi: &u16| -> bool { + groups + .get(*gi as usize) + .is_some_and(|g: &WmoGroupInfo| g.interior) + }; doodads .iter() - .zip(owner) - .map(|(d, owner)| { - let Some(gi) = owner else { + .enumerate() + .map(|(di, d)| { + let Some(gi) = owner.get(di).copied().flatten() else { return DoodadBase::Exterior; }; - let interior = groups - .get(*gi as usize) - .is_some_and(|g: &WmoGroupInfo| g.interior); - if !interior { + // Exterior is absorbing across the whole referrer set — not just the first referrer. + let all_interior = refs + .get(di) + .is_some_and(|gs| !gs.is_empty() && gs.iter().all(interior_group)); + if !all_interior { return DoodadBase::Exterior; } let rgb = [d.color[0], d.color[1], d.color[2]]; @@ -443,7 +465,7 @@ fn resolve_doodad_bases( ambient: cap96(rgb), diffuse: floor112(rgb), light_refs: group_light_refs - .get(*gi as usize) + .get(gi as usize) .cloned() .unwrap_or_default(), }) @@ -624,6 +646,7 @@ impl AssetLoader for WmoModelLoader { sidn: sub.sidn, // MOMT SIDN (0x10) — the authored night-glow colour window: sub.window, // MOMT WINDOW (0x20) — the interior midpoint light additive: sub.additive, // always false for WMO (additive WMO batches deferred) + env_map: false, // M2-only (the WMO env/specular overlay is a separate path) no_depth_write: false, // WMO uses the standard opaque/transparent depth state no_depth_test: false, fog_policy: sub.fog_policy, // always Scene for WMO (remap_submesh's default) @@ -640,14 +663,16 @@ impl AssetLoader for WmoModelLoader { } // Resolve every MODD placement's base once — placements, colours, MODR ownership, and MOLR // light lists are all fixed in the root/groups, so nothing here is ever re-derived. Both - // MODR inversions come off the same refs: the first-owner one freezes the lighting lane at - // create, the full-set one is the per-visible-group cull key. + // MODR inversions come off the same refs: the first-owner one names the MOLR set the + // interior fold gates on, the full-set one is both the per-visible-group cull key and the + // exterior-wins lane test. let doodad_owner = modr_owners(root.doodads().len(), &group_doodad_refs); let doodad_groups = modr_refs(root.doodads().len(), &group_doodad_refs); let doodad_base = resolve_doodad_bases( root.doodads(), root.group_infos(), &doodad_owner, + &doodad_groups, &group_light_refs, ); @@ -742,7 +767,14 @@ mod doodad_base_tests { let raised = as_bytes(floor112([56, 28, 14])); assert_eq!(raised[0], 112); // max channel lands on the 112 value assert_eq!(raised, [112, 56, 28]); // pure scale — hue/saturation preserved - assert_eq!(as_bytes(floor112([0, 0, 0])), [0, 0, 0]); // black stays black (no divide) + // Black stays black — and that IS the reference, not just our divide-by-zero guard. The + // raise re-emits through HSV, and `RGBtoHSV(0,0,0)` gives S=0/H=−1 (`7bbcb5`/`7bbccd`) so + // `HSVtoRGB` would return the grey `(V,V,V)` (`7bbd76`) — but the caller SCALES the value + // rather than setting it (`6a78a5 fild thresh · 6a78ab fidiv max · 6a78b1 fmul V`), and + // `V·thresh/max` is 0 when V is. `6a780e cmp dl,1 / 6a7813 mov bl,1` forces max to 1 only + // to keep that `fidiv` finite. So a zero-colour interior prop commits black in the real + // client too, and the black Booty Bay arch was a LANE bug, never this (decision 0969). + assert_eq!(as_bytes(floor112([0, 0, 0])), [0, 0, 0]); } #[test] @@ -773,10 +805,11 @@ mod doodad_base_tests { let modr = vec![vec![0u16], vec![1u16]]; let molr = vec![vec![7u16, 9u16], vec![3u16]]; let owner = modr_owners(doodads.len(), &modr); + let refs = modr_refs(doodads.len(), &modr); // The same inversion the portal cull keys props on (decision 0689): owned props name their // group, the unreferenced one names none — so it is the one prop the cull can't hide. assert_eq!(owner, vec![Some(0), Some(1), None]); - let bases = resolve_doodad_bases(&doodads, &groups, &owner, &molr); + let bases = resolve_doodad_bases(&doodads, &groups, &owner, &refs, &molr); match &bases[0] { DoodadBase::Interior(b) => { assert_eq!(b.light_refs, vec![7, 9]); @@ -789,6 +822,118 @@ mod doodad_base_tests { assert_eq!(bases[2], DoodadBase::Exterior); } + /// EXTERIOR WINS over every interior referrer, whatever the MODR order — the reference's def is + /// per (MODD, placement) and its classify makes the exterior bit absorbing (`0x695aa0`: + /// `695ba2 jne` refuses to re-mark interior, `695bbd/695bc3` clears interior and sets exterior). + /// Booty Bay's entrance arch is the live case: g22 (interior) names it FIRST and g42 (exterior) + /// second, its MODD colour is `#000000`, and first-referrer-wins therefore drew it as a pure + /// black silhouette at the town gate (decision 0969). + #[test] + fn one_exterior_referrer_makes_the_whole_prop_exterior() { + let doodads = vec![ + prop([0, 0, 0, 255]), // g0 (interior) names it first, g1 (exterior) also names it + prop([0, 0, 0, 255]), // interior groups only + ]; + let groups = [grp(true), grp(false), grp(true)]; + // g0 names both props, g1 (exterior) names only prop 0, g2 (interior) names only prop 1. + let modr = vec![vec![0u16, 1u16], vec![0u16], vec![1u16]]; + let molr = vec![vec![7u16], Vec::new(), vec![4u16]]; + let owner = modr_owners(doodads.len(), &modr); + let refs = modr_refs(doodads.len(), &modr); + assert_eq!( + owner, + vec![Some(0), Some(0)], + "g0 is the first referrer of both" + ); + let bases = resolve_doodad_bases(&doodads, &groups, &owner, &refs, &molr); + assert_eq!( + bases[0], + DoodadBase::Exterior, + "the exterior referrer wins even though an interior group names it first" + ); + match &bases[1] { + // Interior-only: the lane still applies, and a zero MODD colour still commits black — + // the reference's own arithmetic (`0x6a77e0` forces max=1 only to dodge the divide, so + // the HSV raise re-emits value 0·112/1 = 0). Only its MOLR fixtures can light it. + DoodadBase::Interior(b) => { + assert_eq!(b.ambient, [0.0; 3]); + assert_eq!(b.diffuse, [0.0; 3]); + assert_eq!(b.light_refs, vec![7]); + } + other => panic!("interior-only prop must keep the MODD-colour lane, got {other:?}"), + } + } + + /// GOLDEN, on the shipped asset that produced the report: Booty Bay's entrance arch + /// (`BootyBay.wmo` MODD[3], `BootyBayEntrance_02`, MODF uid 118213 at world + /// (-14258.29, 330.26, 44.00)) resolves **Exterior**. Its MODR referrers are g22 (interior, + /// first) and g42 (exterior), and its MODD colour is `#000000` — so first-referrer-wins put it + /// on the interior lane with an all-zero base and drew it as a pure black silhouette over the + /// town gate. This reads the real root + group files through the same inversions the loader + /// calls, so it fails the moment the lane law regresses (decision 0969). + #[test] + fn booty_bays_entrance_arch_is_sky_lit_not_a_black_silhouette() { + 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 stem = "World\\wmo\\Azeroth\\Buildings\\Stranglethorn_BootyBay\\BootyBay"; + let chain = benilla_formats::Chain::open(&data).expect("open vanilla patch chain"); + let bytes = chain + .read(&format!("{stem}.wmo")) + .expect("read BootyBay.wmo"); + let root = parse_wmo_root(&bytes).expect("parse BootyBay.wmo"); + let refs: Vec> = (0..root.group_count()) + .map(|gi| { + chain + .read(&format!("{stem}_{gi:03}.wmo")) + .map(|g| wmo_group_doodad_refs(&g)) + .unwrap_or_default() + }) + .collect(); + + let arch = 3usize; + assert!( + root.doodads()[arch] + .model + .to_ascii_lowercase() + .contains("bootybayentrance_02"), + "MODD[3] should be the entrance arch, got {}", + root.doodads()[arch].model + ); + assert_eq!( + root.doodads()[arch].color, + [0, 0, 0, 255], + "the arch's baked MODD colour is unlit black — that is what made the lane load-bearing" + ); + let owner = modr_owners(root.doodads().len(), &refs); + let groups = modr_refs(root.doodads().len(), &refs); + assert_eq!( + owner[arch], + Some(22), + "an INTERIOR group still names the arch first" + ); + assert!( + groups[arch] + .iter() + .any(|&g| !root.group_infos()[g as usize].interior), + "…and an EXTERIOR group also names it — the case exterior-wins decides" + ); + let bases = resolve_doodad_bases( + root.doodads(), + root.group_infos(), + &owner, + &groups, + &vec![Vec::new(); root.group_count() as usize], + ); + assert_eq!( + bases[arch], + DoodadBase::Exterior, + "the entrance arch must take the sky-lit lane, not the all-zero MODD-colour base" + ); + } + /// A group with NO MOLR chunk gives its doodads an EMPTY light set — the abbey's group 3 case: /// the director's stand receives zero point light, its own flame included (machine-zero in the /// reference decode). @@ -798,6 +943,7 @@ mod doodad_base_tests { &[prop([120, 110, 150, 255])], &[grp(true)], &[Some(0)], + &[Arc::from(vec![0u16])], &[Vec::new()], ); match &bases[0] { @@ -807,11 +953,11 @@ mod doodad_base_tests { } /// A MODD named by several groups resolves to its **first** referencing group, and the mapping is - /// dense over the doodad list — the LIGHTING lane keys off this (the create that freezes the base - /// runs once, on the first visible-group walk to reach it). Out-of-range MODR entries (a - /// malformed group) are ignored rather than panicking. + /// dense over the doodad list — the interior fold's MOLR set keys off this (the create that fills + /// the baked words runs once, on the first visible-group walk to reach it). Out-of-range MODR + /// entries (a malformed group) are ignored rather than panicking. #[test] - fn a_shared_modd_is_lit_by_its_first_referencing_group() { + fn a_shared_modd_takes_its_first_referencing_groups_molr() { // g0 names doodad 2; g1 names 0 and 2; g2 names 1 — plus a ref past the end of the list. let modr = vec![vec![2u16], vec![0u16, 2u16], vec![1u16, 99u16]]; assert_eq!( diff --git a/crates/benilla-formats/examples/wmo_chunk_census.rs b/crates/benilla-formats/examples/wmo_chunk_census.rs new file mode 100644 index 00000000..04b42bb1 --- /dev/null +++ b/crates/benilla-formats/examples/wmo_chunk_census.rs @@ -0,0 +1,105 @@ +//! Chunk-integrity census over WMO files: walk the top-level chunk list the way the loader's +//! [`find_wmo_chunk`] walk does and report every file whose chunk stream does not tile exactly — a +//! chunk whose declared size overruns EOF. The reference **clamps** such a chunk (wow-re +//! `models.md` "WMO chunk-structure contract": *"reads chunks while the 8-byte header is in-bounds +//! and clamps the last chunk to EOF … e.g. `Undercity_144.wmo`'s MOGP runs 1 B past EOF, +//! tolerated"*), so an overrun here must cost us nothing; a file listed as losing a chunk is a bug. +//! +//! ```text +//! cargo run -p benilla-formats --example wmo_chunk_census -- # one building +//! cargo run -p benilla-formats --example wmo_chunk_census -- --all # the whole corpus +//! ``` +//! +//! Output is Blizzard-derived — pipe it to the scratchpad, never into the repo. + +/// Walk `b`'s chunk stream as a pure **data** question: is it exactly tiled, or does some chunk +/// declare more bytes than the file holds? Returns `(tag, declared_end, file_len)` for the first +/// over-declared chunk. Whether the loader survives it is asked separately, of the loader itself. +fn overrun(b: &[u8]) -> Option<(String, usize, usize)> { + let mut off = 0usize; + while off + 8 <= b.len() { + let magic: String = b[off..off + 4].iter().rev().map(|&c| c as char).collect(); + let size = u32::from_le_bytes([b[off + 4], b[off + 5], b[off + 6], b[off + 7]]) as usize; + let end = match off.checked_add(8).and_then(|s| s.checked_add(size)) { + Some(e) => e, + None => return Some((magic, usize::MAX, b.len())), + }; + if end > b.len() { + return Some((magic, end, b.len())); + } + off = end; + } + None +} + +fn main() -> anyhow::Result<()> { + let arg = std::env::args() + .nth(1) + .ok_or_else(|| anyhow::anyhow!("usage: wmo_chunk_census | --all"))?; + let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data"); + let mut chain = benilla_formats::open_chain(&data)?; + + let paths: Vec = if arg == "--all" { + let mut v: Vec = chain + .list()? + .into_iter() + .map(|e| e.name) + .filter(|n| n.to_ascii_lowercase().ends_with(".wmo")) + .collect(); + v.sort(); + v.dedup(); + v + } else { + let root = chain.read_file(&arg)?; + let root = benilla_formats::parse_wmo_root(&root)?; + let stem = arg.strip_suffix(".wmo").unwrap_or(&arg); + std::iter::once(arg.clone()) + .chain((0..root.group_count()).map(|gi| format!("{stem}_{gi:03}.wmo"))) + .collect() + }; + + let (mut checked, mut empty, mut overrun_n, mut lost) = (0u32, 0u32, 0u32, 0u32); + for p in &paths { + let Ok(b) = chain.read_file(p) else { continue }; + if b.is_empty() { + empty += 1; + continue; + } + checked += 1; + // The loader's own verdict, not a re-implementation of it: a group file must yield its MOGP + // header (flags + portal-ref span — losing it dead-ends the portal flood), a root must parse. + let is_group = b.len() > 16 && &b[12..16] == b"PGOM"; + let loads = if is_group { + benilla_formats::wmo_group_header(&b).is_some() + } else { + benilla_formats::parse_wmo_root(&b).is_ok() + }; + if !loads { + lost += 1; + println!("{p}: the loader gets NO {} out of it", { + if is_group { + "MOGP header" + } else { + "root" + } + }); + } + if let Some((tag, end, len)) = overrun(&b) { + overrun_n += 1; + println!( + "{p}: {tag} declares end {end}, file is {len} ({} over) — loader {}", + end.saturating_sub(len), + if loads { + "tolerates it" + } else { + "LOSES THE CHUNK" + } + ); + } + } + println!( + "{checked} wmo files walked ({empty} empty stubs): {overrun_n} with an over-declared chunk, \ + {lost} the loader cannot read" + ); + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs index c1801c61..80d13b1d 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/m2dump.rs @@ -898,6 +898,12 @@ pub fn m2batch(chain: &mut Chain, internal_path: &str) -> Result<()> { if s.billboard.is_some() { flags.push("BILLBOARD"); } + // This batch's texcoords are GENERATED (`texture_unit_lookup > 2`), so the `uv` line below + // reports the authored UVs the runtime does *not* read — a degenerate span there is the + // asset saying "supply these", not a defect in the model. + if s.env_map { + flags.push("ENV-MAP"); + } if s.alpha_anim.is_some() { flags.push("alpha-anim"); } @@ -1220,7 +1226,18 @@ pub fn m2alpha(chain: &mut Chain, internal_path: &str) -> Result<()> { /// (each is a [`benilla_formats::ParticleEmitterDef`] predicate), plus any bit the loader maps /// to nothing — an unmapped bit on a model that looks wrong is a lead, not noise. fn part_flags(flags: u32) -> String { - const NAMED: [(u32, &str); 11] = [ + const NAMED: [(u32, &str); 13] = [ + // The reference has NO "lit" bit — 0x1 is the UNLIT flag, the inverse of the wiki lore + // (wow-re `part-scene-multipliers.md` §1). Naming it the way the binary reads it is the + // point: an emitter WITHOUT this bit is the one that takes the scene's light, and that + // silent majority-of-one is what a dump has to make visible. + (0x0001, "unlit"), + // Runtime `rt+0x1ac` bit 0x10 (loader `0x70fd13` → `0x7b5d00`): the emitter's live + // particles are heap-sorted back-to-front on view depth before the quad writer runs + // (`0x7b3a10`), instead of drawn in pool order. NOT YET IMPLEMENTED — we draw pool + // order for every emitter; harmless where a cloud is one flat colour (alpha coverage is + // order-independent), visible where it is not. + (0x0002, "depthSortParticles(TODO)"), (0x0010, "modelSpace"), (0x0020, "sizeByInstanceScale"), (0x0040, "inheritEmitterMotion"), @@ -1287,7 +1304,7 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { for (i, e) in emitters.iter().enumerate() { let [px, py, pz] = e.position; println!( - "\n#{i:<3} bone {:<4} pos ({px:.3}, {py:.3}, {pz:.3}) {:?} · {:?} · {}", + "\n#{i:<3} bone {:<4} pos ({px:.3}, {py:.3}, {pz:.3}) {:?} · {:?} · {} · {}", e.bone, e.shape, e.blend, @@ -1295,7 +1312,11 @@ pub fn m2part(chain: &mut Chain, internal_path: &str) -> Result<()> { 0 => "head", 1 => "tail", _ => "head+tail", - } + }, + // The scene-light verdict the renderer will take (`ParticleEmitterDef::lit`) — the + // difference between "this sheet is shaded by the world" and "this sheet is a + // full-white cutout", which no other line here shows. + if e.lit { "LIT" } else { "unlit" } ); println!( " texture {} atlas {}x{} flags 0x{:04x} [{}]", diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index f64b2c53..dbdf3b8c 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -308,6 +308,18 @@ enum Command { /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. prefix: Option, }, + /// Sweep every `.m2` (optionally under a path prefix) and census the batches whose texture + /// coordinates are **GENERATED, not authored** — the sphere-map environment stages + /// (`texture_unit_lookup[texCoordSet] > 2`, the reference's gate at `0x70b8bd`). Such a batch + /// carries no usable UVs *by design* (the artist collapses the mesh onto one point because the + /// runtime supplies the coordinates), so a renderer that reads the vertex UV paints the whole + /// surface in **one texel** of a reflection sheet — silently, with no missing geometry to + /// notice. `DEGENERATE` marks exactly that population; the rest carry unused leftover UVs and + /// misdraw as a static smear instead. The instrument behind the Deeprun Tram glass tube. + Envmapscan { + /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. + prefix: Option, + }, /// Sweep every `.m2` and report which sampler ADDRESS MODES the corpus asks of each texture /// path, and how many paths are asked for more than one — the design check behind decision /// 0763 (the mode lives on the sampler, which rides the `Image`, which is keyed by path). @@ -480,6 +492,19 @@ enum Command { /// Case-insensitive substring of the prop's model path (e.g. `lightray`); all if omitted. filter: Option, }, + /// Sweep every WMO **root** (optionally under a path prefix) and list the placed MODD props + /// the INTERIOR lighting lane commits as **literal black**. That lane's entire base light is + /// the MODD entry's own baked colour (ambient `cap96`, diffuse `floor112` — `0x694e90` → + /// `0x6a77e0`), and the floor leg is a hue-preserving scale by `112/max`, so a colour of + /// exactly `#000000` has nothing to raise and both words come out zero: the prop is lit by + /// nothing but its owning group's MOLR fixtures, and a group with none in range draws a pure + /// black silhouette. The `ALSO-EXT` column is the divergence half — a prop an EXTERIOR group's + /// MODR *also* names is one the reference reaches through that group too, while our + /// single-instance first-referrer-wins ownership pins it to the interior lane from every angle. + Darkpropscan { + /// Internal-path prefix to limit the sweep (e.g. `world\wmo\azeroth`); all if omitted. + prefix: Option, + }, /// List individual doodad (MDDF) and WMO (MODF) placements around a world position whose /// model path contains a substring — position, Euler rotation (deg), scale, uniqueId. The /// "point at a thing in the world, tell me exactly how it's placed" instrument: an @@ -642,6 +667,7 @@ fn main() -> Result<()> { Command::Partslotscan { prefix } => scan::partslotscan(&mut chain, prefix.as_deref())?, Command::Seqclockscan { prefix } => scan::seqclockscan(&mut chain, prefix.as_deref())?, Command::Uvwrapscan { prefix } => scan::uvwrapscan(&mut chain, prefix.as_deref())?, + Command::Envmapscan { prefix } => scan::envmapscan(&mut chain, prefix.as_deref())?, Command::Texmodescan { prefix } => scan::texmodescan(&mut chain, prefix.as_deref())?, Command::Fxordercensus { prefix } => scan::fxordercensus(&mut chain, prefix.as_deref())?, Command::Shardcensus { prefix } => scan::shardcensus(&mut chain, prefix.as_deref())?, @@ -692,6 +718,7 @@ fn main() -> Result<()> { internal_path, filter, } => scan::wmodoodads(&mut chain, &internal_path, filter.as_deref())?, + Command::Darkpropscan { prefix } => scan::darkpropscan(&mut chain, prefix.as_deref())?, 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 deleted file mode 100644 index 76ace6ce..00000000 --- a/crates/benilla-formats/src/bin/benilla-extract/scan.rs +++ /dev/null @@ -1,3642 +0,0 @@ -//! Corpus-scan reports: `ribbonscan` (which models carry ribbon emitters), `groundscan` (which -//! models author flat ground-plane render geometry), and `doodadscan` (how much placed content in -//! a map block actually animates) — the population instruments that sweep many models/placements -//! rather than dumping one. - -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; - -use anyhow::{Context, Result}; -use benilla_formats::{Chain, M2AnimSummary, M2Light}; - -use crate::{model_key, yn}; - -/// Dump a WMO root's placed-prop tables: every MODD doodad with its MODS set membership and its -/// OWNING group(s) read from the group files' MODR lists — the relation the reference instantiates -/// from (`0x695aa0` loops a *visible* group's own refs, wow-re `m2-interior-doodad-base-light.md` -/// §453). A prop referenced by NO group is never created by the real client at all — the -/// divergence decision 0689 names and benilla still spawns. This answers "which props exist here, -/// who owns them, and which would the reference even draw" in one read (the B30/B32 question). -pub fn wmodoodads(chain: &mut Chain, raw_path: &str, filter: Option<&str>) -> Result<()> { - let root_path = raw_path.replace('/', "\\").to_ascii_lowercase(); - let bytes = chain - .read_file(&root_path) - .with_context(|| format!("reading WMO root '{root_path}'"))?; - let root = benilla_formats::parse_wmo_root(&bytes) - .with_context(|| format!("parsing WMO root '{root_path}'"))?; - let set_names = mods_set_names(&bytes); - - // MODD index -> the groups whose MODR reference it (the ownership relation). - let stem = root_path.strip_suffix(".wmo").unwrap_or(&root_path); - let mut owners: BTreeMap> = BTreeMap::new(); - let mut groups_read = 0u32; - for gi in 0..root.group_count() { - let group_path = format!("{stem}_{gi:03}.wmo"); - let Ok(gbytes) = chain.read_file(&group_path) else { - continue; - }; - groups_read += 1; - for r in benilla_formats::wmo_group_doodad_refs(&gbytes) { - owners.entry(r).or_default().push(gi); - } - } - - println!( - "{} doodad(s), {} set(s), {} group(s) ({} group file(s) read)", - root.doodads().len(), - root.doodad_sets().len(), - root.group_count(), - groups_read, - ); - for (si, s) in root.doodad_sets().iter().enumerate() { - let name = set_names.get(si).map(String::as_str).unwrap_or("?"); - println!( - "set {si:>2} [{:>5}..{:>5}) count {:>5} {name}", - s.start, - s.start + s.count, - s.count - ); - } - - let needle = filter.map(str::to_ascii_lowercase); - let infos = root.group_infos(); - let mut shown = 0u32; - let mut orphans_total = 0u32; - let mut orphans_shown = 0u32; - for (i, d) in root.doodads().iter().enumerate() { - let orphan = !owners.contains_key(&(i as u16)); - if orphan { - orphans_total += 1; - } - if let Some(n) = &needle { - if !model_key(&d.model).contains(n.as_str()) { - continue; - } - } - shown += 1; - if orphan { - orphans_shown += 1; - } - let sets: Vec = root - .doodad_sets() - .iter() - .enumerate() - .filter(|(_, s)| (s.start..s.start + s.count).contains(&(i as u32))) - .map(|(si, _)| si.to_string()) - .collect(); - let owner_cell = match owners.get(&(i as u16)) { - Some(gs) => gs - .iter() - .map(|&g| { - let class = match infos.get(g as usize) { - Some(gi) if gi.interior => "INT", - Some(_) => "EXT", - None => "?", - }; - format!("g{g}({class})") - }) - .collect::>() - .join(" "), - None => "ORPHAN".into(), - }; - println!( - "modd {i:>5} pos ({:>8.2}, {:>8.2}, {:>8.2}) scale {:.3} color #{:02x}{:02x}{:02x}{:02x} sets [{}] {owner_cell} {}", - d.position[0], d.position[1], d.position[2], - d.scale, - d.color[0], d.color[1], d.color[2], d.color[3], - sets.join(","), - d.model, - ); - } - match needle { - Some(_) => eprintln!( - "{shown} doodad(s) matched ({orphans_shown} ORPHAN); {orphans_total} orphan(s) among all {}", - root.doodads().len() - ), - None => eprintln!( - "{orphans_total} of {} doodad(s) are ORPHANS (in no group's MODR — the reference never instantiates these)", - root.doodads().len() - ), - } - Ok(()) -} - -/// The MODS set names (`char name[20]` per 32-byte record) — `WmoDoodadSet` keeps only the ranges, -/// so read the names off the raw root bytes here (top-level chunks are `[magic][size][data]`, magic -/// on disk reversed: MODS → `SDOM`). -fn mods_set_names(bytes: &[u8]) -> Vec { - let mut off = 0usize; - while off + 8 <= bytes.len() { - let size = u32::from_le_bytes([ - bytes[off + 4], - bytes[off + 5], - bytes[off + 6], - bytes[off + 7], - ]) as usize; - let Some(data_end) = (off + 8).checked_add(size) else { - return Vec::new(); - }; - if data_end > bytes.len() { - return Vec::new(); - } - if &bytes[off..off + 4] == b"SDOM" { - return bytes[off + 8..data_end] - .chunks_exact(32) - .map(|rec| { - let name = &rec[..20]; - let end = name.iter().position(|&b| b == 0).unwrap_or(20); - String::from_utf8_lossy(&name[..end]).into_owned() - }) - .collect(); - } - off = data_end; - } - Vec::new() -} - -/// Sweep every `.m2` (under `prefix`, if given) and classify every BILLBOARD batch by which way its -/// geometry faces — see the `Bbfacescan` command doc for why the sign decides visibility. -/// -/// A billboard bone puts the model's **+X** toward the viewer (`billboard-bone-law`, spherical arm), -/// so a batch whose winding normal is +X faces the camera and a −X one faces away. Single-sided -/// (`two_sided` false, i.e. no material `0x04`), the away-facing ones are backface-culled by the -/// reference from every angle — they are authored placeholders the author never saw. -pub fn bbfacescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - let (mut scanned, mut cards) = (0u32, 0u32); - // The four card populations. `away_single` is the one the renderer's forced-two-sided override - // changes: those and only those become visible when a card is not allowed to be culled. - let (mut toward, mut away_single, mut away_two, mut edge_on) = (0u32, 0u32, 0u32, 0u32); - // …and the batches that are not cards at all. A facing only exists for a batch that IS one - // plane; a closed solid has faces every way round, backface culling never hides it, and - // sampling its first triangle answers a question it doesn't have (see `plane_normal`). - let mut solid = 0u32; - // …and the other side of the same gate: batches the split REFUSED because their geometry is - // welded to a billboard bone (`RenderSubmesh::welded_billboard`, decisions 0839/0841). These - // carry no `billboard` at all, so the card loop below never sees them — they are counted from - // the flag the render lanes read, which is what makes this a cross-check of `bbscan`'s SEAM - // column (same models, counted from the two ends of the rule) rather than a re-derivation. - let (mut welded, mut weld_models) = (0u32, 0u32); - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); - let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else { - continue; - }; - let mut lines = Vec::new(); - let weld = subs.iter().filter(|s| s.welded_billboard).count() as u32; - welded += weld; - weld_models += u32::from(weld > 0); - for (i, s) in subs.iter().enumerate() { - let Some(bb) = &s.billboard else { continue }; - cards += 1; - // Not one plane ⇒ not a card. Counted apart rather than classified by a triangle. - if s.plane_normal().is_none() { - solid += 1; - continue; - } - // The winding normal's X component: +1 faces the viewer, −1 faces away. - let Some(fx) = facet_x(s) else { - edge_on += 1; - continue; - }; - if fx > 0.5 { - toward += 1; - } else if fx < -0.5 { - if s.two_sided { - away_two += 1; - } else { - away_single += 1; - lines.push(format!( - " batch {i:>3}: {:?} {:?} {} verts facetX {fx:+.2} tex {}", - bb.kind, - s.blend, - s.positions.len(), - s.texture.as_deref().unwrap_or("NONE"), - )); - } - } else { - edge_on += 1; - } - } - if !lines.is_empty() { - println!("{name}"); - for l in lines { - println!("{l}"); - } - } - } - eprintln!( - "{scanned} models scanned, {cards} billboard batch(es): {toward} toward, \ - {away_single} away+single-sided (reference culls these), {away_two} away+two-sided, \ - {edge_on} edge-on/degenerate, {solid} NOT PLANAR (3-D geometry — no facing to decide); \ - plus {welded} WELDED batch(es) across {weld_models} model(s) the split refused \ - (skinned, not carded)" - ); - Ok(()) -} - -/// The X component of a batch's first-triangle winding normal, in WoW model space — `None` when the -/// triangle is degenerate or the normal lies in the YZ plane (nothing to decide a facing from). -fn facet_x(s: &benilla_formats::RenderSubmesh) -> Option { - let tri = s.indices.get(..3)?; - let p = |i: u32| s.positions.get(i as usize).copied(); - let (a, b, c) = (p(tri[0])?, p(tri[1])?, p(tri[2])?); - let (u, v) = ( - [b[0] - a[0], b[1] - a[1], b[2] - a[2]], - [c[0] - a[0], c[1] - a[1], c[2] - a[2]], - ); - let n = [ - u[1] * v[2] - u[2] * v[1], - u[2] * v[0] - u[0] * v[2], - u[0] * v[1] - u[1] * v[0], - ]; - let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); - (len > 1e-9).then(|| n[0] / len) -} - -/// Sweep every `.m2` and census its **attachment addressing**: how many records it authors, how -/// many attach ids its AttachLookup resolves, and — the point of the report — where the two -/// disagree, i.e. where "scan the table for a record with this id" answers differently from the -/// reference's `lookup[id]` (`0x710310`). -/// -/// Built for decision 0805 (item glows hang on the item model's ids 0..4) to answer the question -/// that decides whether the lookup can be adopted globally: which models change hands? One line -/// per divergent model — `+id` = an id only the lookup reaches, `-id` = an id only a table scan -/// reaches (a record the reference cannot address at all), `id:a→b` = same id, different record. -pub fn attachscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - let (mut scanned, mut with_points, mut divergent) = (0u32, 0u32, 0u32); - let mut by_family: BTreeMap = BTreeMap::new(); - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - let Ok(format) = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes)) else { - continue; - }; - let model = format.model(); - scanned += 1; - if model.attachments.is_empty() && model.attach_lookup.is_empty() { - continue; - } - with_points += 1; - // What a table scan would answer (first record per id — the pre-0805 rule) against what - // the lookup answers, compared by the record each id lands on. - let mut scan: BTreeMap = BTreeMap::new(); - for (i, a) in model.attachments.iter().enumerate() { - scan.entry(a.id).or_insert(i); - } - let lookup: BTreeMap = (0..model.attach_lookup.len()) - .filter_map(|id| { - let idx = *model.attach_lookup.get(id)?; - (idx != 0xffff && (idx as usize) < model.attachments.len()) - .then_some((id as u16, idx as usize)) - }) - .collect(); - let mut diffs: Vec = Vec::new(); - for (&id, &idx) in &lookup { - match scan.get(&id) { - Some(&s) if s != idx => diffs.push(format!("{id}:{s}→{idx}")), - Some(_) => {} - None => diffs.push(format!("+{id}")), - } - } - for &id in scan.keys() { - if !lookup.contains_key(&id) { - diffs.push(format!("-{id}")); - } - } - if !diffs.is_empty() { - divergent += 1; - *by_family.entry(model_key(&name)).or_default() += 1; - println!( - "{:<62} recs {:>2} lookup {:>2} {}", - name, - model.attachments.len(), - lookup.len(), - diffs.join(" ") - ); - } - } - eprintln!( - "{scanned} models scanned, {with_points} with attachment data, \ - {divergent} where a table scan disagrees with the lookup" - ); - for (family, n) in by_family { - eprintln!(" {family}: {n}"); - } - Ok(()) -} - -/// Sweep every `.m2` in the chain and list the models carrying RIBBON emitters. -pub fn ribbonscan(chain: &mut Chain) -> Result<()> { - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| n.to_ascii_lowercase().ends_with(".m2")) - .collect(); - let (mut scanned, mut hits) = (0u32, 0u32); - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let n = benilla_formats::m2_ribbon_emitter_count(&bytes); - if n > 0 { - hits += 1; - println!("{n:>2} {name}"); - } - } - eprintln!("{scanned} models scanned, {hits} with ribbons"); - Ok(()) -} - -/// Sweep every `.m2` (under `prefix`, if given) and list the models whose MATERIAL table authors -/// blend mode 5 (Mod) / 6 (Mod2x) — the multiply-blend census (decision 0528). One line per -/// matching model: its per-material `(flags, blend)` pairs and path. The raw header read (materials -/// count/ofs at `0x84`, 4-byte `{u16 flags, u16 blend}` records) matches `benilla-m2`'s parse. -pub fn blendscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - let (mut scanned, mut hits) = (0u32, 0u32); - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let at = |o: usize| -> Option { - Some(u32::from_le_bytes(bytes.get(o..o + 4)?.try_into().ok()?)) - }; - let (Some(n), Some(ofs)) = (at(0x84), at(0x88)) else { - continue; - }; - let mats: Vec<(u16, u16)> = (0..n as usize) - .filter_map(|i| { - let o = ofs as usize + i * 4; - let b = bytes.get(o..o + 4)?; - Some(( - u16::from_le_bytes([b[0], b[1]]), - u16::from_le_bytes([b[2], b[3]]), - )) - }) - .collect(); - if mats.iter().any(|&(_, blend)| blend == 5 || blend == 6) { - hits += 1; - println!("{mats:?} {name}"); - } - } - eprintln!("{scanned} models scanned, {hits} with Mod/Mod2x materials"); - Ok(()) -} - -/// Sweep every `.m2` (under `prefix`, if given) and list the models whose particle emitters -/// carry any bit of `mask` in their file flags — see the `Partscan` command doc. One line per -/// matching emitter: its index, full flag word, shape/type, and the model path. -pub fn partscan(chain: &mut Chain, mask: u32, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - let (mut scanned, mut hits, mut emitters) = (0u32, 0u32, 0u32); - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let Ok(defs) = benilla_formats::parse_m2_particle_emitters(&bytes) else { - continue; - }; - let mut hit = false; - for (i, d) in defs.iter().enumerate() { - if d.flags & mask != 0 { - hit = true; - emitters += 1; - println!( - "e{i} flags {:#010x} {:?} {} {name}", - d.flags, - d.shape, - match d.head_tail { - 0 => "head", - 1 => "tail", - _ => "head+tail", - }, - ); - } - } - if hit { - hits += 1; - } - } - eprintln!( - "{scanned} models scanned, {hits} with mask {mask:#x} emitters ({emitters} emitters)" - ); - Ok(()) -} - -/// Sweep every `.m2` (under `prefix`, if given) and classify its billboard usage — see the -/// `Bbscan` command doc. Output per model: the authored arms and how many vertices ride each -/// DIRECTLY (primary bone is the billboard bone — the card path) vs INHERITED (primary bone -/// descends from one — the joint-palette path, decision 0205), then the same question for the -/// model's **particle emitters and ribbons** (`fx[…]`) — the population behind decision 0813: an -/// emitter on (or under) a billboard bone has a camera-dependent origin, because the reference -/// folds the record position through the *replaced* palette matrix -/// (wow-re `part-anchoring-live-bone.md` §1 row 3 · `m2emitspine::particle_bone_xform`). -pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - let arm = |k: benilla_formats::BillboardKind| match k { - benilla_formats::BillboardKind::Spherical => "S", - benilla_formats::BillboardKind::LockX => "X", - benilla_formats::BillboardKind::LockY => "Y", - benilla_formats::BillboardKind::LockZ => "Z", - }; - let (mut scanned, mut hits) = (0u32, 0u32); - // Corpus totals: models exercising each arm, split by how the geometry rides it. - let mut direct_models: HashMap<&'static str, u32> = HashMap::new(); - let mut inherited_models: HashMap<&'static str, u32> = HashMap::new(); - // …and the effect riders (particles/ribbons on a billboard chain). - let mut fx_models = 0u32; - let mut fx_total: HashMap = HashMap::new(); - // …and the seam population (see the classification below). - let (mut seam_models, mut seam_bones) = (0u32, 0u32); - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let Ok(fmt) = benilla_m2::parse_m2(&mut std::io::Cursor::new(bytes.as_slice())) else { - continue; - }; - let m = fmt.model(); - let kinds: Vec> = m - .bones - .iter() - .map(|b| benilla_formats::BillboardKind::from_bone_flags(b.flags.bits())) - .collect(); - if kinds.iter().all(|k| k.is_none()) { - continue; - } - hits += 1; - // Nearest billboard ancestor (self included) per bone — the arm whose palette - // replacement a vertex on this bone inherits. Bounded walk (M2 parents precede - // children; the bound is just a malformed-file guard). - let ancestor_arm = |mut i: usize| -> Option<(bool, benilla_formats::BillboardKind)> { - let mut hops = 0; - loop { - if let Some(k) = kinds.get(i).copied().flatten() { - return Some((hops == 0, k)); - } - let p = usize::try_from(*m.bones.get(i).map(|b| &b.parent)?).ok()?; - hops += 1; - if p >= m.bones.len() || hops > m.bones.len() { - return None; - } - i = p; - } - }; - let (mut direct, mut inherited): (HashMap<&str, u32>, HashMap<&str, u32>) = - (HashMap::new(), HashMap::new()); - for v in &m.vertices { - match ancestor_arm(v.bone_indices[0] as usize) { - Some((true, k)) => *direct.entry(arm(k)).or_default() += 1, - Some((false, k)) => *inherited.entry(arm(k)).or_default() += 1, - None => {} - } - } - // SEAM bones: billboard bones whose geometry is welded to the rest of the model — by a - // partial vertex weight, or by a triangle it shares with a static neighbour. The reference - // skins per vertex, so such geometry BENDS (a flap's root stays on the body while its tip - // swings to the camera); a rigid card cannot express that at all, and moving the group - // rigidly tears the flap in two. These are the bones the split declines (decision 0839), - // read through the renderer's own predicate so this census cannot drift from it. - let seam = benilla_formats::non_separable_billboard_bones(&bytes); - let fmt_counts = |m: &HashMap<&str, u32>| { - let mut v: Vec = m.iter().map(|(k, n)| format!("{k}:{n}")).collect(); - v.sort(); - v.join(" ") - }; - // The EFFECT riders: a particle emitter / ribbon whose bone chain reaches a billboard - // bone has a camera-dependent live frame (its record position rides the replaced palette - // matrix), so a consumer that places it at the rest pose puts it in the wrong place. - // `d` = the effect's own bone is the billboard bone, `i` = it descends from one. - let mut fx: HashMap = HashMap::new(); - let mut tally = |tag: &str, bone: u16| { - if let Some((direct, k)) = ancestor_arm(bone as usize) { - let key = format!("{tag}{}{}", if direct { "d" } else { "i" }, arm(k)); - *fx.entry(key).or_default() += 1; - } - }; - for e in benilla_formats::parse_m2_particle_emitters(&bytes) - .unwrap_or_default() - .iter() - { - tally("p", e.bone); - } - for r in benilla_formats::parse_m2_ribbon_emitters(&bytes) - .unwrap_or_default() - .iter() - { - tally("r", r.bone); - } - let fx_counts = { - let mut v: Vec = fx.iter().map(|(k, n)| format!("{k}:{n}")).collect(); - v.sort(); - v.join(" ") - }; - let bones: String = kinds.iter().flatten().map(|&k| arm(k)).collect(); - let seam_col = if seam.is_empty() { - String::new() - } else { - seam_models += 1; - seam_bones += seam.len() as u32; - format!( - " SEAM[{}]", - seam.iter() - .map(u16::to_string) - .collect::>() - .join(",") - ) - }; - println!( - "{bones:>4} direct[{}] inherited[{}] fx[{fx_counts}]{seam_col} {name}", - fmt_counts(&direct), - fmt_counts(&inherited) - ); - if !fx.is_empty() { - fx_models += 1; - for (k, n) in &fx { - *fx_total.entry(k.clone()).or_default() += n; - } - } - for k in direct.keys() { - *direct_models - .entry(match *k { - "S" => "S", - "X" => "X", - "Y" => "Y", - _ => "Z", - }) - .or_default() += 1; - } - for k in inherited.keys() { - *inherited_models - .entry(match *k { - "S" => "S", - "X" => "X", - "Y" => "Y", - _ => "Z", - }) - .or_default() += 1; - } - } - let tot = |m: &HashMap<&'static str, u32>| { - let mut v: Vec = m.iter().map(|(k, n)| format!("{k}:{n}")).collect(); - v.sort(); - v.join(" ") - }; - let fx_tot = { - let mut v: Vec = fx_total.iter().map(|(k, n)| format!("{k}:{n}")).collect(); - v.sort(); - v.join(" ") - }; - eprintln!( - "{scanned} models scanned, {hits} with billboard bones; models by arm — direct(card) [{}] inherited(palette) [{}]; {fx_models} with EFFECTS on a billboard chain [{fx_tot}]; {seam_models} with SEAM billboard bones ({seam_bones} total) — geometry welded to the model, which a rigid card tears", - tot(&direct_models), - tot(&inherited_models) - ); - Ok(()) -} - -/// Sweep every `.m2` (under `prefix`, if given) and census the **geometry a non-character model -/// draws that the reference may not** — the population instrument behind the bug channel's -/// "stray untextured primitive" family. Three independent signals per model, all read through the -/// renderer's own batch resolution (`m2batch`'s), so the report can't drift from the mechanism: -/// -/// - **MULTI-GEOSET** — more than one distinct `skinSectionId`. The character compositor selects -/// among these; every other spawn path draws **all** of them, so this is exactly the population -/// an unfiltered creature/doodad/effect draw over-renders (`Creature\Banshee\Banshee.m2` is the -/// pinned case: `0`×17 + `402`×9). -/// - **UNTEX** — batches with no texture *and* no runtime slot that fills one: neither a character -/// composite slot ([`benilla_formats::RenderSubmesh::char_slot`]) nor a creature skin variation -/// ([`benilla_formats::RenderSubmesh::skin_slot`], filled at spawn from `CreatureDisplayInfo`). -/// Both fills are ordinary, so counting them would drown the signal — 324 of 420 `Creature\` -/// models carry a skin slot. What is left is geometry nothing can texture. -/// - **TINY** — batches of at most 2 faces: the literal single-triangle/quad primitives. -/// -/// A model is listed when it trips any of the three. `m2batch` then explains one model in full. -pub fn geosetscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - let (mut scanned, mut hits) = (0u32, 0u32); - let (mut multi_models, mut untex_models, mut tiny_models) = (0u32, 0u32, 0u32); - // Top-level directory → multi-geoset model count, so the report says *where* the population - // lives (Creature/, Spells/, World/…) rather than only how big it is. - let mut by_dir: BTreeMap = BTreeMap::new(); - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); - let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else { - continue; - }; - let mut geosets: BTreeMap = BTreeMap::new(); - let (mut untex, mut tiny) = (0u32, 0u32); - for s in &subs { - *geosets.entry(s.geoset_id).or_default() += 1; - if s.texture.is_none() && s.char_slot.is_none() && s.skin_slot.is_none() { - untex += 1; - } - if !s.indices.is_empty() && s.indices.len() <= 6 { - tiny += 1; - } - } - let multi = geosets.len() > 1; - if !multi && untex == 0 && tiny == 0 { - continue; - } - hits += 1; - if multi { - multi_models += 1; - let top = name.split_once('\\').map(|(d, _)| d).unwrap_or(""); - *by_dir.entry(top.to_ascii_lowercase()).or_default() += 1; - } - if untex > 0 { - untex_models += 1; - } - if tiny > 0 { - tiny_models += 1; - } - let hist = geosets - .iter() - .map(|(id, n)| format!("{id}×{n}")) - .collect::>() - .join(" "); - let mut marks = Vec::new(); - if multi { - marks.push(format!("MULTI-GEOSET({})", geosets.len())); - } - if untex > 0 { - marks.push(format!("UNTEX({untex})")); - } - if tiny > 0 { - marks.push(format!("TINY({tiny})")); - } - println!( - "{:>3} batches [{hist}] {} {name}", - subs.len(), - marks.join(" ") - ); - } - let dirs = by_dir - .iter() - .map(|(d, n)| format!("{d}={n}")) - .collect::>() - .join(" "); - eprintln!( - "{scanned} models scanned, {hits} listed — {multi_models} MULTI-GEOSET, \ - {untex_models} with UNTEX batches, {tiny_models} with TINY batches; \ - multi-geoset by top dir: [{dirs}]" - ); - Ok(()) -} - -/// Sweep every `.m2` (under `prefix`, if given) and report models that author flat ground-plane -/// render geometry — the population instrument for the class of spell effects that lie in the -/// model-space XY plane at z≈0 (WoW axes, Z up) and get buried by sloped terrain (Battle Shout's -/// crescents are the canonical case: 6 batches, each a 4-vert quad, every vertex exactly z=0, each -/// quad skinned 100% to a single bone). Per batch (same batch/geoset resolution `m2batch` uses): -/// FLAT if every vertex has `|z| <= 0.01` in model space; flat batches sub-classify QUAD-1BONE -/// (the crescent shape — [`benilla_formats::RenderSubmesh::ground_quad`], the ground-fx decal -/// lane's own detector) vs OTHER-FLAT (flat but not that shape, staying on the ordinary render -/// path) — which decides how general the renderer mechanism has to be. -pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - let (mut scanned, mut hits, mut all_flat, mut mixed) = (0u32, 0u32, 0u32, 0u32); - let (mut quad_total, mut other_total) = (0u32, 0u32); - // The hover census: every ground-quad-SHAPED batch whose uniform plane sits ABOVE z = 0 - // (any height — the shape test is the renderer's own, the ceiling is lifted), the population - // that decides where the decal lane's hover ceiling (`GROUND_HOVER_MAX`) can sit. - let mut hovers: Vec<(f32, String)> = Vec::new(); - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); - let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else { - continue; - }; - let total_batches = subs.len(); - let (mut flat_count, mut quad_count, mut other_count) = (0u32, 0u32, 0u32); - let mut blend_modes: Vec = Vec::new(); - let mut model_hovers: Vec = Vec::new(); - for s in &subs { - if s.positions.is_empty() { - continue; - } - if let Some((_, hover)) = s.ground_quad_hover(f32::INFINITY) { - if hover > 0.01 { - hovers.push((hover, name.clone())); - model_hovers.push(hover); - } - } - if !s.positions.iter().all(|v| v[2].abs() <= 0.01) { - continue; - } - flat_count += 1; - let bm = format!("{:?}", s.blend); - if !blend_modes.contains(&bm) { - blend_modes.push(bm); - } - // The RENDERER's own detector, so this report is exactly what the ground-fx - // decal lane will do with each batch — the instrument can't drift from the - // mechanism it measures. - if s.ground_quad().is_some() { - quad_count += 1; - } else { - other_count += 1; - } - } - if flat_count == 0 && model_hovers.is_empty() { - continue; - } - hits += 1; - quad_total += quad_count; - other_total += other_count; - if flat_count as usize == total_batches { - all_flat += 1; - } else { - mixed += 1; - } - blend_modes.sort(); - let hover_note = if model_hovers.is_empty() { - String::new() - } else { - format!( - " hover[{}]", - model_hovers - .iter() - .map(|h| format!("{h:.2}")) - .collect::>() - .join(" ") - ) - }; - println!( - "{total_batches:>3} batches {flat_count:>3} flat ({quad_count:>2} quad-1bone, {other_count:>2} other-flat){hover_note} blend[{}] {name}", - blend_modes.join(" ") - ); - } - eprintln!( - "{scanned} models scanned, {hits} with flat batches ({all_flat} all-flat, {mixed} mixed); flat batches: {quad_total} QUAD-1BONE, {other_total} OTHER-FLAT" - ); - if !hovers.is_empty() { - hovers.sort_by(|a, b| a.0.total_cmp(&b.0)); - eprintln!( - "{} hovering quad-shaped batches (uniform plane z > 0.01), sorted:", - hovers.len() - ); - for (h, name) in &hovers { - eprintln!(" z {h:>7.3} {name}"); - } - } - Ok(()) -} - -/// List individual doodad (MDDF) and WMO (MODF) placements around a world position whose model -/// path contains `filter` (case-insensitive) — the per-placement position / Euler rotation / -/// scale / uniqueId ground truth an orientation investigation needs (`doodadscan` only -/// aggregates). -pub fn placescan( - chain: &mut Chain, - map: &str, - center_x: f32, - center_y: f32, - tile_radius: u32, - filter: &str, -) -> Result<()> { - let tiles = benilla_formats::load_tiles_around(chain, map, center_x, center_y, tile_radius) - .with_context(|| format!("loading tiles around ({center_x}, {center_y}) on {map}"))?; - eprintln!("{} tile(s) loaded", tiles.len()); - let needle = filter.to_ascii_lowercase(); - let mut seen: HashSet = HashSet::new(); - let mut hits = 0u32; - for (_, tile) in &tiles { - for d in &tile.doodads { - if seen.insert(d.unique_id) && model_key(&d.model).contains(&needle) { - hits += 1; - println!( - "MDDF uid {:>8} pos ({:>9.2}, {:>9.2}, {:>8.2}) rot deg ({:>7.2}, {:>7.2}, {:>7.2}) scale {:.3} {}", - d.unique_id, - d.position[0], d.position[1], d.position[2], - d.rotation[0], d.rotation[1], d.rotation[2], - d.scale, - d.model, - ); - } - } - for w in &tile.wmos { - if seen.insert(w.unique_id) && w.model.to_ascii_lowercase().contains(&needle) { - hits += 1; - println!( - "MODF uid {:>8} pos ({:>9.2}, {:>9.2}, {:>8.2}) rot deg ({:>7.2}, {:>7.2}, {:>7.2}) {}", - w.unique_id, - w.position[0], w.position[1], w.position[2], - w.rotation[0], w.rotation[1], w.rotation[2], - w.model, - ); - } - } - } - eprintln!("{hits} placement(s) matched '{filter}'"); - Ok(()) -} - -/// 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. -pub fn doodadscan( - chain: &mut Chain, - map: &str, - center_x: f32, - center_y: f32, - tile_radius: u32, -) -> Result<()> { - let tiles = benilla_formats::load_tiles_around(chain, map, center_x, center_y, tile_radius) - .with_context(|| format!("loading tiles around ({center_x}, {center_y}) on {map}"))?; - eprintln!("{} tile(s) loaded", tiles.len()); - - // Direct M2 placements (MDDF) — deduped by uniqueId, which a tile-straddling doodad - // repeats identically across every tile it touches (decision-0021 terrain streamer's own - // dedup key; see `benilla_formats::Doodad::unique_id`). - let mut seen_doodad_ids: HashSet = HashSet::new(); - let mut m2_instances: HashMap = HashMap::new(); - // WMO placements (MODF) — same dedup, by their own uniqueId. - let mut seen_wmo_ids: HashSet = HashSet::new(); - let mut wmo_instances: HashMap = HashMap::new(); - for (_, tile) in &tiles { - for d in &tile.doodads { - if seen_doodad_ids.insert(d.unique_id) { - *m2_instances.entry(model_key(&d.model)).or_insert(0) += 1; - } - } - for w in &tile.wmos { - if seen_wmo_ids.insert(w.unique_id) { - *wmo_instances.entry(w.model.clone()).or_insert(0) += 1; - } - } - } - let direct_m2_instances: u32 = m2_instances.values().sum(); - eprintln!( - "{direct_m2_instances} MDDF doodad placement(s) across {} unique M2 model(s)", - m2_instances.len() - ); - eprintln!( - "{} MODF WMO placement(s) across {} unique WMO model(s)", - seen_wmo_ids.len(), - wmo_instances.len() - ); - - // Fold each unique WMO's doodad-set-**0** M2 props into the same instance table (set 0 is - // the WMO's always-on global set, per `WmoDoodadSet` doc), each multiplied by that WMO's - // own (deduped) placement count — one building placement = one instance of every set-0 prop - // it carries. - let mut wmo_root_failures = 0u32; - for (wmo_path, &count) in &wmo_instances { - let root_path = wmo_path.to_ascii_lowercase(); // matches `load_wmo`'s own normalization - let root = chain - .read_file(&root_path) - .ok() - .and_then(|bytes| benilla_formats::parse_wmo_root(&bytes).ok()); - let Some(root) = root else { - wmo_root_failures += 1; - continue; - }; - let Some(set0) = root.doodad_sets().first() else { - continue; - }; - let range = set0.start as usize..(set0.start as usize + set0.count as usize); - for wd in root.doodads().get(range).unwrap_or(&[]) { - if !wd.model.is_empty() { - *m2_instances.entry(model_key(&wd.model)).or_insert(0) += count; - } - } - } - if wmo_root_failures > 0 { - eprintln!(" ({wmo_root_failures} WMO root(s) failed to read/parse — skipped)"); - } - - // Per-unique-model animation summary — one parse per model regardless of instance count. - let mut summaries: HashMap = HashMap::new(); - let mut parse_failures: Vec<(String, String)> = Vec::new(); - for model in m2_instances.keys() { - match benilla_formats::load_m2_animation_summary(chain, model) { - Ok(s) => { - summaries.insert(model.clone(), s); - } - Err(e) => parse_failures.push((model.clone(), e.to_string())), - } - } - - let total_instances: u32 = m2_instances.values().sum(); - let total_models = m2_instances.len(); - println!(); - println!( - "=== totals ({total_instances} M2 instance(s), {total_models} unique model(s), {} parse failure(s)) ===", - parse_failures.len() - ); - - type ChannelCheck = (&'static str, fn(&M2AnimSummary) -> bool); - let checks: [ChannelCheck; 9] = [ - ("seq-0 bone motion", |s| s.seq0_has_bone_motion), - ("moving seq0, >1 variation", |s| { - s.seq0_has_bone_motion && s.seq0_variation_count > 1 - }), - ("global-seq bones", |s| !s.global_seq_channels.is_empty()), - ("animated transparency", |s| s.transparency_tracks.1 > 0), - ("animated color", |s| { - s.color_rgb_tracks.1 > 0 || s.color_alpha_tracks.1 > 0 - }), - ("texture transforms", |s| s.texture_transform_count > 0), - ("particles", |s| s.particle_emitter_count > 0), - ("emitter on moving bone", |s| { - s.emitter_bones.iter().any(|e| e.chain_animated()) - }), - ("ribbons", |s| s.ribbon_emitter_count > 0), - ]; - let report_row = |label: &str, pred: &dyn Fn(&M2AnimSummary) -> bool| { - let inst: u32 = m2_instances - .iter() - .filter_map(|(m, &c)| summaries.get(m).filter(|s| pred(s)).map(|_| c)) - .sum(); - let models = summaries.values().filter(|s| pred(s)).count(); - println!( - " {label:22} {inst:>6} instances ({:5.1}%) {models:>4} models ({:5.1}%)", - 100.0 * f64::from(inst) / f64::from(total_instances.max(1)), - 100.0 * models as f64 / total_models.max(1) as f64, - ); - }; - for (label, pred) in checks { - report_row(label, &pred); - } - report_row("NO animated channel", &|s| s.is_fully_static()); - - println!(); - println!("=== top 30 models by instance count ==="); - println!( - "{:>6} {:>4} {:>4} {:>5} {:>5} {:>4} {:>4} {:>4} model", - "count", "seq0", "gseq", "trns", "clr", "txfm", "part", "ribn" - ); - let mut ranked: Vec<(&String, &u32)> = m2_instances.iter().collect(); - ranked.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); - for (model, &count) in ranked.iter().take(30) { - match summaries.get(*model) { - Some(s) => println!( - "{count:>6} {:>4} {:>4} {:>5} {:>5} {:>4} {:>4} {:>4} {model}", - yn(s.seq0_has_bone_motion), - yn(!s.global_seq_channels.is_empty()), - yn(s.transparency_tracks.1 > 0), - yn(s.color_rgb_tracks.1 > 0 || s.color_alpha_tracks.1 > 0), - yn(s.texture_transform_count > 0), - yn(s.particle_emitter_count > 0), - yn(s.ribbon_emitter_count > 0), - ), - None => println!("{count:>6} {model}"), - } - } - - // The rare material channels by NAME (each is <1% of instances, so the top-30 table - // almost never surfaces them): the exact models the phase-2/3 material-animation work - // verifies against. - println!(); - println!("=== material-channel models (animated transparency / color / UV) ==="); - let mut rare: Vec<(&String, &u32)> = m2_instances - .iter() - .filter(|(m, _)| { - summaries.get(*m).is_some_and(|s| { - s.transparency_tracks.1 > 0 - || s.color_rgb_tracks.1 > 0 - || s.color_alpha_tracks.1 > 0 - || s.texture_transform_count > 0 - }) - }) - .collect(); - rare.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); - for (model, &count) in &rare { - let s = &summaries[*model]; - println!( - "{count:>6} trns:{} clr:{} txfm:{} {model}", - s.transparency_tracks.1, - s.color_rgb_tracks.1 + s.color_alpha_tracks.1, - s.texture_transform_count, - ); - } - - // Moving-seq0 models with a variation chain, by NAME (the wow-re §4a random-variation - // arm correction): the exact placed models where variationIdx −1 vs 0 is visible at all. - println!(); - println!("=== moving-seq0 multi-variation models ==="); - let mut varied: Vec<(&String, &u32)> = m2_instances - .iter() - .filter(|(m, _)| { - summaries - .get(*m) - .is_some_and(|s| s.seq0_has_bone_motion && s.seq0_variation_count > 1) - }) - .collect(); - varied.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); - for (model, &count) in &varied { - let s = &summaries[*model]; - println!( - "{count:>6} {} variation(s) of seq0 {model}", - s.seq0_variation_count - ); - } - - // Emitters hosted on a moving bone chain, by NAME (0130 phase 4 grounding): the exact - // placed models where emitter bone-follow is visible at all — an emitter on a static - // chain sits at its rest pose whether or not we attach it. - println!(); - println!("=== emitter-on-moving-bone models ==="); - let mut movers: Vec<(&String, &u32)> = m2_instances - .iter() - .filter(|(m, _)| { - summaries - .get(*m) - .is_some_and(|s| s.emitter_bones.iter().any(|e| e.chain_animated())) - }) - .collect(); - movers.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); - for (model, &count) in &movers { - let s = &summaries[*model]; - let moving = s - .emitter_bones - .iter() - .filter(|e| e.chain_animated()) - .count(); - println!( - "{count:>6} {moving}/{} emitter(s) on moving bones {model}", - s.emitter_bones.len() - ); - } - - if !parse_failures.is_empty() { - println!(); - println!("=== parse failures ({}) ===", parse_failures.len()); - for (model, err) in &parse_failures { - println!(" {model}: {err}"); - } - } - - Ok(()) -} - -// --------------------------------------------------------------------------- -// partcensus — the particle FEATURE CENSUS (see the `Partcensus` command doc). -// --------------------------------------------------------------------------- - -/// The census's raw-extras view of one on-disk emitter record: the fields the shipped -/// [`benilla_formats::ParticleEmitterDef`] deliberately does not carry (yet). Read straight off -/// the record bytes (stride/header per the parser's module doc): the two model-filename M2Arrays -/// at `+0x18` (geometry model — 3-D "model particles") and `+0x20` (recursion model — per-particle -/// child emitters), and the emission-rate track's interpolation word (`+0xdc`, 0 = step). -struct RecordExtras { - geometry_model: Option, - recursion_model: Option, - rate_interp: u16, - rate_keys: u32, -} - -/// Read the raw extras for every emitter record in an M2 (empty if not an MD20 or no emitters). -fn record_extras(bytes: &[u8]) -> Vec { - const STRIDE: usize = 0x1f8; - let u16_at = |o: usize| u16::from_le_bytes([bytes[o], bytes[o + 1]]); - let u32_at = |o: usize| u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()); - let str_at = |count: usize, ofs: usize| -> Option { - if count == 0 || ofs == 0 || ofs + count > bytes.len() { - return None; - } - let s = String::from_utf8_lossy(&bytes[ofs..ofs + count]) - .trim_end_matches('\0') - .to_string(); - (!s.is_empty()).then_some(s) - }; - if bytes.len() < 0x144 || &bytes[..4] != b"MD20" { - return Vec::new(); - } - let count = u32_at(0x13c) as usize; - let base = u32_at(0x140) as usize; - if count == 0 || count > 256 || base + count * STRIDE > bytes.len() { - return Vec::new(); - } - (0..count) - .map(|i| { - let e = base + i * STRIDE; - RecordExtras { - geometry_model: str_at(u32_at(e + 0x18) as usize, u32_at(e + 0x1c) as usize), - recursion_model: str_at(u32_at(e + 0x20) as usize, u32_at(e + 0x24) as usize), - rate_interp: u16_at(e + 0xdc), - rate_keys: u32_at(e + 0xdc + 0x14), - } - }) - .collect() -} - -/// model key → the spells whose visual chain plays that model (any kit stage, or the missile). -fn spell_attribution(chain: &mut Chain) -> HashMap> { - let (Ok(spells), Ok(visuals)) = ( - benilla_formats::load_spell_catalog(chain), - benilla_formats::load_spell_visual_catalog(chain), - ) else { - eprintln!("(spell/visual DBCs unavailable — census runs without spell attribution)"); - return HashMap::new(); - }; - let mut map: HashMap> = HashMap::new(); - for (id, sp) in spells.iter() { - let Some(st) = visuals.stages(sp.visual) else { - continue; - }; - let mut push = |effect: u32| { - if effect != 0 { - if let Some(p) = visuals.effect_path(effect) { - map.entry(crate::model_key(p)) - .or_default() - .push((id, sp.name.clone())); - } - } - }; - for kit_id in [st.precast, st.cast, st.impact, st.state, st.channel] { - if kit_id == 0 { - continue; - } - if let Some(kit) = visuals.kit(kit_id) { - for (_, effect) in kit.effects() { - push(effect); - } - } - } - push(st.missile_model); - } - map -} - -/// One census dimension's tally. `model_count` counts every distinct model exactly; -/// `models` keeps the first 64 (sorted) for the example listings. -#[derive(Default)] -struct Tally { - emitters: u32, - model_count: u32, - last_model: String, - models: std::collections::BTreeSet, -} - -impl Tally { - /// The walk visits each model's emitters consecutively, so "new model" is a change of name. - fn hit(&mut self, model: &str) { - self.emitters += 1; - if self.last_model != model { - self.model_count += 1; - self.last_model = model.to_string(); - } - if self.models.len() < 64 { - self.models.insert(model.to_string()); - } - } -} - -/// Sweep every `.m2` (under `prefix`, if given) and census which particle-emitter FEATURES the -/// corpus actually authors — see the `Partcensus` command doc. -pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let attribution = spell_attribution(chain); - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - - let mut tallies: std::collections::BTreeMap<&'static str, Tally> = - std::collections::BTreeMap::new(); - let (mut scanned, mut with_emitters, mut total_emitters) = (0u32, 0u32, 0u32); - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let Ok(defs) = benilla_formats::parse_m2_particle_emitters(&bytes) else { - continue; - }; - if defs.is_empty() { - continue; - } - with_emitters += 1; - let extras = record_extras(&bytes); - let key = crate::model_key(&name); - for (i, d) in defs.iter().enumerate() { - total_emitters += 1; - let mut hit = |k: &'static str| tallies.entry(k).or_default().hit(&key); - match d.shape { - benilla_formats::ParticleShape::Plane => hit("shape:plane"), - benilla_formats::ParticleShape::Sphere => hit("shape:sphere"), - benilla_formats::ParticleShape::Spline => hit("shape:SPLINE"), - } - // ANISOTROPIC plane rectangles — the population on which the areaLength↔areaWidth axis - // pairing is observable at all (a SQUARE area renders identically either way). 0563 and - // 0566 both pre-named "a 90°-wrong ANISOTROPIC effect" as this lane's suspect, but no - // instrument could LIST that population, so the swapped pairing outlived both audits - // until Gressil's 0.1 × 1.1 blade smoke drew its curtain across the blade. Bucketed by - // aspect so a thin curtain (load-bearing) separates from a near-square (invisible). - let now = d.params.sample(None, 0.0, 0.0); - if d.shape == benilla_formats::ParticleShape::Plane { - let lo = now.area_length.abs().min(now.area_width.abs()); - let hi = now.area_length.abs().max(now.area_width.abs()); - if hi > 1e-4 { - let aspect = if lo > 1e-4 { hi / lo } else { f32::INFINITY }; - if aspect >= 4.0 { - hit("plane-area:ANISOTROPIC >=4:1"); - } else if aspect >= 1.5 { - hit("plane-area:anisotropic >=1.5:1"); - } - } - } - // ANIMATED parameter channels (decision 0844): the population the value[0] flatten - // silently mis-rendered — Frost Nova's emission radius riding its ring out, Arcane - // Explosion's riding its dome. Tallied per channel so the census names WHICH knob - // actually moves in the corpus. - const PARAM_KEYS: [&str; 9] = [ - "param-anim:speed", - "param-anim:speedVar", - "param-anim:latitude", - "param-anim:longitude", - "param-anim:gravity", - "param-anim:lifespan", - "param-anim:areaLength", - "param-anim:areaWidth", - "param-anim:zSource", - ]; - for (c, (_, slots)) in d.params.channel_views().iter().enumerate() { - if slots - .iter() - .any(|k| k.is_some_and(|k| k.len() > 1 && k.iter().any(|&(_, v)| v != k[0].1))) - { - hit(PARAM_KEYS[c]); - } - } - match d.head_tail { - 0 => hit("type:head"), - 1 => hit("type:tail"), - _ => hit("type:head+tail"), - } - match d.blend { - benilla_formats::ParticleBlend::Add => hit("blend:add"), - benilla_formats::ParticleBlend::Alpha => hit("blend:alpha"), - benilla_formats::ParticleBlend::Opaque => hit("blend:opaque"), - } - // The raw blend byte disambiguates what the parsed enum folds: 5 = Mod, 6 = Mod2x. - { - const STRIDE: usize = 0x1f8; - let base = u32::from_le_bytes(bytes[0x140..0x144].try_into().unwrap()) as usize; - match bytes[base + i * STRIDE + 0x28] { - 5 => hit("blend:MOD(5)"), - 6 => hit("blend:MOD2X(6)"), - _ => {} - } - } - if d.flags & 0x1 == 0 { - hit("flag:LIT(0x1 clear)"); - } - for (bit, label) in [ - (0x8u32, "flag:0x8 texenv"), - (0x10, "flag:0x10 model-space"), - (0x20, "flag:0x20 scale-by-instance"), - (0x40, "flag:0x40 MOTION-VEL-INHERIT"), - (0x80, "flag:0x80 KILL-OUTBOUND"), - (0x100, "flag:0x100 sphere-up"), - (0x200, "flag:0x200"), - (0x400, "flag:0x400 tail-age-clamp"), - (0x800, "flag:0x800 SPAWN-PATH-SPREAD"), - (0x1000, "flag:0x1000 xy-quad"), - (0x2000, "flag:0x2000 GROUND-SNAP"), - (0x4000, "flag:0x4000 FOLLOW-DELTA"), - (0x8000, "flag:0x8000 burst"), - ] { - if d.flags & bit != 0 { - hit(label); - } - } - if d.flags >> 16 != 0 { - hit("flag:HIGH-BITS(>0xffff)"); - } - if d.spin > 0.0 { - hit("spin:positive"); - } - if d.spin < 0.0 { - hit("spin:negative"); - } - if d.twinkle_percent < 1.0 { - hit("twinkle:percent<1"); - } - if u32::from(d.tile_cols) * u32::from(d.tile_rows) > 1 { - hit("tiles:atlas"); - if !d.tile_cols.is_power_of_two() { - hit("tiles:NON-POW2-COLS"); - } - } - if now.z_source != 0.0 { - hit("kernel:zSource"); - } - if now.gravity < 0.0 { - hit("kernel:negative-gravity"); - } - if d.shape == benilla_formats::ParticleShape::Sphere - && now.vertical_range > 3.0 - && now.horizontal_range == 0.0 - { - hit("kernel:edge-on-ring(lat±π,lon0)"); - } - if let Some(x) = extras.get(i) { - if x.geometry_model.is_some() { - hit("MODEL-PARTICLES(geometry)"); - } - if x.recursion_model.is_some() { - hit("CHILD-EMITTERS(recursion)"); - } - if x.rate_keys > 1 && x.rate_interp != 0 { - hit("rate:LERP-RAMP(interp!=0)"); - // A BURST emitter with a lerp rate track would arm a near-zero count at its - // rising edge — if the corpus authored one, the burst edge law needs a re-look. - if d.burst() { - hit("rate:BURST+LERP(suspect)"); - } - } - } - } - } - - println!( - "== particle feature census prefix={} ({scanned} models scanned, {with_emitters} with emitters, {total_emitters} emitters)", - prefix.unwrap_or(""), - ); - for (k, t) in &tallies { - let ex: Vec<&str> = t.models.iter().take(3).map(|s| s.as_str()).collect(); - println!( - "{:>6} emitters {:>4} models {k} e.g. {}", - t.emitters, - t.model_count, - ex.join(" · ") - ); - } - - // The full model list — with spell attribution — for the dimensions that decide mechanism - // scope (the UPPERCASE keys: unimplemented or folded legs, plus the odd corners). - let detail: &[&str] = &[ - "MODEL-PARTICLES(geometry)", - "CHILD-EMITTERS(recursion)", - "shape:SPLINE", - "blend:MOD(5)", - "blend:MOD2X(6)", - "flag:LIT(0x1 clear)", - "flag:0x40 MOTION-VEL-INHERIT", - "flag:0x80 KILL-OUTBOUND", - "flag:0x800 SPAWN-PATH-SPREAD", - "flag:0x2000 GROUND-SNAP", - "flag:0x4000 FOLLOW-DELTA", - "flag:HIGH-BITS(>0xffff)", - "tiles:NON-POW2-COLS", - "rate:LERP-RAMP(interp!=0)", - ]; - for k in detail { - let Some(t) = tallies.get(k) else { continue }; - println!(); - println!( - "=== {k} ({} emitters, {} models)", - t.emitters, t.model_count - ); - for m in t.models.iter().take(16) { - let spells = attribution.get(m).map_or(String::new(), |v| { - let mut seen = HashSet::new(); - let names: Vec = v - .iter() - .filter(|(id, _)| seen.insert(*id)) - .take(3) - .map(|(id, n)| format!("{id} {n}")) - .collect(); - if names.is_empty() { - String::new() - } else { - format!( - " [{}{}]", - names.join(", "), - if v.len() > 3 { ", …" } else { "" } - ) - } - }); - println!(" {m}{spells}"); - } - if t.model_count as usize > 16 { - println!(" … {} more", t.model_count as usize - 16); - } - } - Ok(()) -} - -// --------------------------------------------------------------------------- -// m2lightscan — the M2 dynamic-LIGHT population instrument (see the `M2lightscan` command doc). -// --------------------------------------------------------------------------- - -/// Capitalize an `Item\ObjectComponents\` path component for consistent family-key display -/// regardless of how a given asset's listfile entry happened to be cased (`WEAPON`/`weapon`/ -/// `Weapon` all collapse to `Weapon`). -fn title_case(s: &str) -> String { - let mut chars = s.chars(); - match chars.next() { - Some(first) => { - first.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase() - } - None => String::new(), - } -} - -/// The top-level content-family bucket for an internal M2 path — `m2lightscan`'s summary -/// dimension for "how much content is affected, and of what kind". Derived from the path's first -/// one or two components, case-insensitively. `World\Goober\` (GameObject displays) splits from -/// plain `World\` (ADT-placed doodads / WMO props) because only the latter is on benilla's -/// current M2-light spawn path (`crate::terrain_stream::spawn::fx::spawn_lights_for`); everything -/// not otherwise named folds into `other`. -fn family_of(name: &str) -> String { - let comps: Vec<&str> = name.split('\\').collect(); - let low = |s: &str| s.to_ascii_lowercase(); - match comps.first().map(|s| low(s)).as_deref() { - Some("creature") => "Creature\\".to_string(), - Some("character") => "Character\\".to_string(), - Some("spells") => "Spells\\".to_string(), - Some("item") if comps.get(1).map(|s| low(s)).as_deref() == Some("objectcomponents") => { - match comps.get(2) { - Some(sub) => format!("Item\\ObjectComponents\\{}\\", title_case(sub)), - None => "Item\\ObjectComponents\\".to_string(), - } - } - Some("world") if comps.get(1).map(|s| low(s)).as_deref() == Some("goober") => { - "World\\Goober\\".to_string() - } - Some("world") => "World\\".to_string(), - _ => "other".to_string(), - } -} - -/// How many rows of the closing colour tally print (the rest are counted, never silently dropped). -const TALLY_ROWS: usize = 20; - -/// Cheap warm/cool/neutral hue classification of a `diffuse_color`, used only to eyeball the -/// colour-tally section of `m2lightscan`'s summary — the warm-torch family vs anything unusual. -fn hue_tag(r: f32, g: f32, b: f32) -> &'static str { - if r >= g && r > b * 1.15 { - "warm" - } else if b > r && b >= g { - "cool" - } else { - "neutral" - } -} - -/// Per-family tally for `m2lightscan`'s summary: how many models in this content family carry -/// lights, how many `type==1` point lights they author in total, how many of those are dark -/// (`visibility_off`), and a handful of example paths. -#[derive(Default)] -struct FamilyStats { - models: u32, - point_lights: u32, - dark: u32, - examples: Vec, -} - -/// Sweep every `.m2` (optionally under a path prefix) and report which models author M2 dynamic -/// LIGHT blocks — the population instrument for the mechanism (decision 0016 / wow-re -/// `system/models/scratch/m2-dynamic-lights.md`). Per model (only models with ≥1 light, printed -/// sorted by path): its `type==1` point-light count vs directional (`type==0`, ambient-feed, not -/// a discrete GL light) count, then per POINT light: bone, model-space position, `diffuse_color × -/// diffuse_intensity` (raw colour, intensity, and the product), authored attenuation start/end, -/// and an `OFF` tag when [`M2Light::visibility_off`] — the one shape (a static `0` visibility -/// key) that keeps a light dark (§9.4). The closing summary is the real deliverable: totals, a -/// breakdown by top-level content family ([`family_of`]) — benilla only spawns these lights for -/// ADT-placed doodads and WMO props today, so this answers how much of the entity path -/// (creatures, held items, GameObjects) is actually missing them — and a cheap diffuse -/// colour×intensity tally ([`hue_tag`]). -pub fn m2lightscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - - // Rounded `(r, g, b) × 100` (int-keyed to stay orderable) — a cheap grouping key for the - // authored diffuse×intensity palette across point lights. - type ColorKey = (i32, i32, i32); - - let (mut scanned, mut hits, mut total_point, mut total_dark) = (0u32, 0u32, 0u32, 0u32); - let mut families: BTreeMap = BTreeMap::new(); - // key -> (hit count, one example model). - let mut color_tally: BTreeMap = BTreeMap::new(); - let mut hit_models: Vec<(String, Vec)> = Vec::new(); - - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let lights = benilla_formats::parse_m2_lights(&bytes); - if lights.is_empty() { - continue; - } - hits += 1; - let point_count = lights.iter().filter(|l| l.is_point()).count() as u32; - let dark_count = lights - .iter() - .filter(|l| l.is_point() && l.visibility_off) - .count() as u32; - total_point += point_count; - total_dark += dark_count; - - let fam = families.entry(family_of(&name)).or_default(); - fam.models += 1; - fam.point_lights += point_count; - fam.dark += dark_count; - if fam.examples.len() < 8 { - fam.examples.push(name.clone()); - } - - for l in lights.iter().filter(|l| l.is_point()) { - let key = ( - (l.diffuse_color[0] * l.diffuse_intensity * 100.0).round() as i32, - (l.diffuse_color[1] * l.diffuse_intensity * 100.0).round() as i32, - (l.diffuse_color[2] * l.diffuse_intensity * 100.0).round() as i32, - ); - color_tally - .entry(key) - .or_insert_with(|| (0, name.clone())) - .0 += 1; - } - - hit_models.push((name, lights)); - } - - hit_models.sort_by(|a, b| a.0.cmp(&b.0)); - for (name, lights) in &hit_models { - let point_count = lights.iter().filter(|l| l.is_point()).count(); - let dir_count = lights.len() - point_count; - println!("{name} {point_count} point, {dir_count} directional"); - for (i, l) in lights.iter().enumerate() { - if !l.is_point() { - continue; - } - let prod = [ - l.diffuse_color[0] * l.diffuse_intensity, - l.diffuse_color[1] * l.diffuse_intensity, - l.diffuse_color[2] * l.diffuse_intensity, - ]; - println!( - " L{i} bone {:>4} pos ({:>9.3}, {:>9.3}, {:>9.3}) diffuse ({:.3}, {:.3}, {:.3}) x {:.3} = ({:.3}, {:.3}, {:.3}) atten [{:.2}, {:.2}]{}", - l.bone, - l.position[0], l.position[1], l.position[2], - l.diffuse_color[0], l.diffuse_color[1], l.diffuse_color[2], - l.diffuse_intensity, - prod[0], prod[1], prod[2], - l.attenuation_start, l.attenuation_end, - if l.visibility_off { " OFF" } else { "" }, - ); - } - } - - println!(); - println!( - "=== summary === {scanned} models scanned, {hits} with light blocks, {total_point} point lights, {total_dark} dark (visibility_off) point lights" - ); - - println!(); - println!("=== by content family ==="); - for (fam, stats) in &families { - println!( - "{fam:<32} {:>4} models {:>4} point lights {:>3} dark e.g. {}", - stats.models, - stats.point_lights, - stats.dark, - stats.examples.join(" · ") - ); - } - - println!(); - println!("=== diffuse colour x intensity tally (point lights, rounded to 0.01) ==="); - let mut ranked: Vec<(&ColorKey, &(u32, String))> = color_tally.iter().collect(); - ranked.sort_by_key(|(_, (count, _))| std::cmp::Reverse(*count)); - for (key, (count, example)) in ranked.iter().take(TALLY_ROWS) { - let (r, g, b) = ( - key.0 as f32 / 100.0, - key.1 as f32 / 100.0, - key.2 as f32 / 100.0, - ); - let tag = hue_tag(r, g, b); - println!("{count:>4}x ({r:.2}, {g:.2}, {b:.2}) {tag:<5} e.g. {example}"); - } - // Never let the top-20 read as "that's all of them". - if let Some(rest) = ranked.len().checked_sub(TALLY_ROWS).filter(|n| *n > 0) { - println!(" … and {rest} rarer colours (top {TALLY_ROWS} shown)"); - } - - Ok(()) -} - -/// Sweep every `.m2` (under `prefix`, if given) and census the models whose batch visibility is -/// **per sequence** — geometry the reference draws in one animation and skips in another, via the -/// verified alpha combine (`A = colourAlpha × weight`, `A ≤ 0` culls; wow-re -/// `m2-alpha-combine-cull.md`). -/// -/// This is the population instrument for the class of bug where a client bakes the material tracks -/// once and draws the result forever: every model listed here has at least one batch whose authored -/// visibility CHANGES between sequences, so a single-sequence bake is guaranteed to be wrong for it -/// in some animation. Per model it reports how many batches are **hidden in the model's first -/// sequence** (what a doodad-shaped bake would show) versus hidden in *some* sequence, so the two -/// failure directions — drawing geometry that should be hidden, and hiding geometry that should -/// draw — are separated. `m2alpha` then explains one model in full. -pub fn alphascan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - let (mut scanned, mut hits) = (0u32, 0u32); - let mut by_dir: BTreeMap = BTreeMap::new(); - let mut rows: Vec<(String, usize, usize, usize)> = Vec::new(); - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); - let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else { - continue; - }; - let seq_count = benilla_formats::parse_m2_animations(&bytes).len(); - if seq_count < 2 { - continue; // a one-sequence model can't disagree with itself - } - // A batch is "hidden in slot s" when its combined factor is 0 across that whole band. The - // sampling grid is coarse on purpose — a batch that so much as flickers non-zero is drawn. - let hidden_in = |sub: &benilla_formats::RenderSubmesh, slot: usize| -> bool { - sub.alpha_anim.as_ref().is_some_and(|a| { - (0..=16u16).all(|k| a.sample(Some(slot), f32::from(k) * 0.25, 0.0) <= 0.0) - }) - }; - let (mut first, mut any, mut varies) = (0usize, 0usize, 0usize); - for sub in &subs { - let h0 = hidden_in(sub, 0); - let mut hid_any = h0; - let mut differs = false; - for slot in 1..seq_count { - let h = hidden_in(sub, slot); - hid_any |= h; - differs |= h != h0; - } - if h0 { - first += 1; - } - if hid_any { - any += 1; - } - if differs { - varies += 1; - } - } - if varies == 0 { - continue; - } - hits += 1; - let top = name.split_once('\\').map(|(d, _)| d).unwrap_or(""); - *by_dir.entry(top.to_ascii_lowercase()).or_default() += 1; - rows.push((name, varies, first, any)); - } - // Loudest first: the models where the most geometry changes hands between sequences. - rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); - println!( - "model varies hid@seq0 hid@any" - ); - for (name, varies, first, any) in rows.iter().take(60) { - println!("{name:<60} {varies:>6} {first:>8} {any:>7}"); - } - if rows.len() > 60 { - println!("… and {} more", rows.len() - 60); - } - println!("\n{hits} of {scanned} models author per-sequence batch visibility"); - println!("by top-level directory:"); - for (dir, n) in &by_dir { - println!(" {dir:<16} {n:>5}"); - } - Ok(()) -} - -/// One bone `M2Track` read straight off the file bytes (v256 stride `0x1c`: interp@0, gseq@2, -/// interpolation_ranges `M2Array`@0x04/0x08, timestamps@0x0c/0x10, values@0x14/0x18). -/// -/// Deliberately raw rather than via `parse_m2_animations`: this instrument's whole job is to -/// compare what the **file** says against what our parser currently emits, so it must not go -/// through the parser under test. -struct RawBoneTrack { - interp: u16, - gseq: u16, - ranges: Vec<(u32, u32)>, - ts: Vec, - vals: Vec<[f32; 4]>, - comps: usize, -} - -impl RawBoneTrack { - fn read(b: &[u8], o: usize, comps: usize) -> Option { - let u32_at = |p: usize| -> Option { - b.get(p..p + 4) - .map(|s| u32::from_le_bytes(s.try_into().unwrap())) - }; - let u16_at = |p: usize| -> Option { - b.get(p..p + 2) - .map(|s| u16::from_le_bytes(s.try_into().unwrap())) - }; - let f32_at = |p: usize| -> Option { - b.get(p..p + 4) - .map(|s| f32::from_le_bytes(s.try_into().unwrap())) - }; - let (interp, gseq) = (u16_at(o)?, u16_at(o + 2)?); - let (rn, ro) = (u32_at(o + 0x04)? as usize, u32_at(o + 0x08)? as usize); - let (tn, to) = (u32_at(o + 0x0c)? as usize, u32_at(o + 0x10)? as usize); - let (vn, vo) = (u32_at(o + 0x14)? as usize, u32_at(o + 0x18)? as usize); - let ranges = (0..rn) - .map_while(|i| Some((u32_at(ro + i * 8)?, u32_at(ro + i * 8 + 4)?))) - .collect(); - let stride = comps * 4; - let n = tn.min(vn); - let mut ts = Vec::with_capacity(n); - let mut vals = Vec::with_capacity(n); - for i in 0..n { - let Some(t) = u32_at(to + i * 4) else { break }; - let mut v = [0.0f32; 4]; - let mut ok = true; - for (c, slot) in v.iter_mut().take(comps).enumerate() { - match f32_at(vo + i * stride + c * 4) { - Some(f) => *slot = f, - None => ok = false, - } - } - if !ok { - break; - } - ts.push(t); - vals.push(v); - } - Some(Self { - interp, - gseq, - ranges, - ts, - vals, - comps, - }) - } - - /// The reference's sample at absolute time `t_ms` for sequence file slot `slot` — FN1 - /// (`0x713d50`) verbatim, then the sampler's own lerp/step leg (wow-re `eval.md` FN1/FN2/FN6). - fn reference(&self, slot: usize, t_ms: u32) -> Option<[f32; 4]> { - let last = self.ts.len().checked_sub(1)?; - // FN1 §1: the window is `ranges[slot]` when the array is present, else the whole key list. - let (lo, hi) = match self.ranges.get(slot) { - Some(&(lo, hi)) => (lo as usize, hi as usize), - None => (0, last), - }; - let (lo, hi) = (lo.min(last), hi.min(last)); - // FN1 §2: a collapsed window resolves to `keys[lo]` outright. - if lo >= hi { - return Some(self.vals[lo]); - } - let mut k0 = lo; - for (k, &ts) in self.ts.iter().enumerate().take(hi + 1).skip(lo) { - if ts <= t_ms { - k0 = k; - } else { - break; - } - } - // FN1 §5: `k1 = k0+1`, bounded by the TOTAL key count — never by the window's `hi`. - if self.interp == 0 || k0 + 1 > last { - return Some(self.vals[k0]); - } - let (t0, t1) = (self.ts[k0], self.ts[k0 + 1]); - if t1 <= t0 { - return Some(self.vals[k0]); - } - let f = (t_ms as f32 - t0 as f32) / (t1 as f32 - t0 as f32); - let (a, b) = (self.vals[k0], self.vals[k0 + 1]); - let mut out = [0.0f32; 4]; - for (i, slot) in out.iter_mut().enumerate() { - *slot = a[i] + (b[i] - a[i]) * f; - } - Some(out) - } - - /// What `models::anim::read_bone_track` emits for this band today: the in-band keys, or — when - /// the band holds none — the single **nearest** out-of-band key (benilla decision 0133). - /// Returns `(value_at_band_start, value_at_band_end, band_was_empty)`. - fn benilla_today(&self, start: u32, end: u32) -> Option<([f32; 4], [f32; 4], bool)> { - let inb: Vec = (0..self.ts.len()) - .filter(|&k| self.ts[k] >= start && self.ts[k] <= end) - .collect(); - if let (Some(&f), Some(&l)) = (inb.first(), inb.last()) { - // Bevy holds the first key before it and the last key after it. - return Some((self.vals[f], self.vals[l], false)); - } - let mut best: Option<(u32, usize)> = None; - for (k, &ts) in self.ts.iter().enumerate() { - let d = if ts < start { start - ts } else { ts - end }; - if best.is_none_or(|(bd, _)| d < bd) { - best = Some((d, k)); - } - } - let (_, k) = best?; - Some((self.vals[k], self.vals[k], true)) - } - - /// Distance between two sampled values in the channel's own units: **degrees** of rotation for - /// a quaternion (numerically stable near identity — `acos(dot)` has a ~0.04° floor in f32), - /// **model units** for a translation/scale vector. - fn delta(&self, a: [f32; 4], b: [f32; 4]) -> f32 { - if self.comps == 4 { - let norm = |q: [f32; 4]| { - let n = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt(); - if n > 0.0 { - [q[0] / n, q[1] / n, q[2] / n, q[3] / n] - } else { - q - } - }; - let (a, mut b) = (norm(a), norm(b)); - if a.iter().zip(b).map(|(x, y)| x * y).sum::() < 0.0 { - b = [-b[0], -b[1], -b[2], -b[3]]; - } - let d = a - .iter() - .zip(b) - .map(|(x, y)| (x - y) * (x - y)) - .sum::() - .sqrt(); - 2.0 * (d / 2.0).min(1.0).asin().to_degrees() - } else { - (0..3) - .map(|i| (a[i] - b[i]) * (a[i] - b[i])) - .sum::() - .sqrt() - } - } -} - -/// Sweep every `.m2` (optionally under a path prefix) and measure, per bone track and per sequence -/// band, how far **our** skeletal parse sits from the **reference's** sampler — the population -/// instrument behind benilla decision 0133's named residual ("an empty band clamps to the nearest -/// authored key … a named approximation of the mid-gap lerp"). -/// -/// Three separately-reported disagreements, each a distinct mechanism (wow-re `eval.md`): -/// -/// - **EMPTY bands** — a band with no keys of its own. We hold the nearest authored key; the -/// reference holds `keys[ranges[slot].lo]`, or lerps across the bracket when the window spans two -/// keys. The 0133 residual proper. -/// - **HELD edges** — a keyed band whose first key is late / last key is early. Bevy holds the edge -/// key; the reference keeps interpolating toward the neighbouring **out-of-band** key, because -/// FN1's `k1 = k0+1` is bounded by the total key count and not by the window. -/// - **STEP tracks** — `interpolation_type == 0`. The reference's samplers branch on it and copy -/// `keys[k0]` with no interpolation; our bone parse emits keys and lets Bevy interpolate, so a -/// snap becomes a glide. -/// -/// Plus the safety check the whole idea rests on: whether any band's own keys fall **outside** the -/// window `ranges[slot]` would search (they never do — if they did, adopting the window would -/// reintroduce the garbage pose 0133 records). -pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - // Above the f32 noise floor of the stable angle formula (~1e-4°) by two orders, and far below - // anything an eye could catch — a slot over this is a real authored difference. - const ROT_EPS: f32 = 0.01; // degrees - const VEC_EPS: f32 = 1e-4; // model units - let (mut scanned, mut models_step, mut models_empty_differ, mut models_edge_differ) = - (0u32, 0u32, 0u32, 0u32); - let (mut slots_keyed, mut slots_empty) = (0u64, 0u64); - let (mut empty_differ, mut edge_differ, mut ranges_absent, mut window_violation) = - (0u64, 0u64, 0u64, 0u64); - let (mut step_tracks, mut total_tracks, mut step_bands) = (0u64, 0u64, 0u64); - let (mut gseq_tracks, mut gseq_multi, mut gseq_orphan) = (0u64, 0u64, 0u64); - let (mut gseq_ranges_absent, mut gseq_ranges_restrict) = (0u64, 0u64); - let mut gseq_orphan_models: BTreeSet = BTreeSet::new(); - let mut gseq_restrict_models: BTreeSet = BTreeSet::new(); - let mut emitted_extra = 0u64; - // (empty-band peak, keyed-edge peak) per channel kind — the bound, not a threshold count. - let (mut peak_rot, mut peak_vec) = ((0.0f32, 0.0f32), (0.0f32, 0.0f32)); - let mut emitted_models: BTreeSet = BTreeSet::new(); - // Worst offender per class: (delta, model, bone, channel, slot). - let mut worst_empty: Vec<(f32, String, usize, &'static str, usize)> = Vec::new(); - let mut worst_edge: Vec<(f32, String, usize, &'static str, usize)> = Vec::new(); - let mut step_models: BTreeMap = BTreeMap::new(); - let mut worst_step: Vec<(f32, String, usize, &'static str, usize, usize)> = Vec::new(); - for name in names { - let Ok(b) = chain.read_file(&name) else { - continue; - }; - if b.len() < 0x40 || &b[0..4] != b"MD20" { - continue; - } - scanned += 1; - let u32_at = |o: usize| -> usize { - b.get(o..o + 4) - .map(|s| u32::from_le_bytes(s.try_into().unwrap()) as usize) - .unwrap_or(0) - }; - // Sequences in FILE order (count@0x1c/ofs@0x20, stride 0x44) — the order `ranges` indexes. - let (sn, so) = (u32_at(0x1c), u32_at(0x20)); - let seqs: Vec<(u32, u32)> = (0..sn) - .map_while(|i| { - let e = so + i * 0x44; - (e + 0x44 <= b.len()).then(|| (u32_at(e + 4) as u32, u32_at(e + 8) as u32)) - }) - .collect(); - // globalSequences @0x14/0x18 — a duration per entry; 0 means the loop has no period. - let (gn, go) = (u32_at(0x14), u32_at(0x18)); - let gseq_period = |g: u16| -> u32 { - let i = g as usize; - if i >= gn { - 0 - } else { - u32_at(go + i * 4) as u32 - } - }; - let (bn, bo) = (u32_at(0x34), u32_at(0x38)); - let (mut m_step, mut m_empty, mut m_edge) = (0u64, 0u64, 0u64); - for bi in 0..bn { - let brec = bo + bi * 0x6c; - if brec + 0x6c > b.len() { - break; - } - for (off, comps, ch) in [(0x0c, 3, "trans"), (0x28, 4, "rot"), (0x44, 3, "scale")] { - let Some(tr) = RawBoneTrack::read(&b, brec + off, comps) else { - continue; - }; - if tr.ts.is_empty() { - continue; - } - total_tracks += 1; - if tr.interp == 0 { - step_tracks += 1; - m_step += 1; - // The step deviation only becomes visible when a band holds TWO keys: that is - // where the reference snaps and we glide. Measure the size of the snap. - for (slot, &(start, end)) in seqs.iter().enumerate() { - if end <= start { - continue; - } - let inb: Vec = (0..tr.ts.len()) - .filter(|&k| tr.ts[k] >= start && tr.ts[k] <= end) - .collect(); - if inb.len() < 2 { - continue; - } - step_bands += 1; - let jump = inb - .windows(2) - .map(|w| tr.delta(tr.vals[w[0]], tr.vals[w[1]])) - .fold(0.0f32, f32::max); - if jump > if comps == 4 { ROT_EPS } else { VEC_EPS } { - worst_step.push((jump, name.clone(), bi, ch, slot, inb.len())); - } - } - } - // A global-sequence track runs on its own clock. `read_bone_track` keeps the - // SINGLE-key case (a constant channel — the stowed-weapon rest quats) and leaves - // the multi-key case to `parse_m2_global_sequence_bones` → the `GlobalSeqDrive` - // lane, which needs a non-zero `globalSequences[gseq]` period. A multi-key channel - // on a ZERO-period global sequence falls between the two and is sampled by - // neither — census that gap, and the shape of the `ranges` window the reference - // would still apply here (FN1 selects the window BEFORE it resolves the gseq - // clock, so a restrictive window would clip the loop). - if tr.gseq != 0xffff { - gseq_tracks += 1; - if tr.ts.len() > 1 { - gseq_multi += 1; - if gseq_period(tr.gseq) == 0 { - gseq_orphan += 1; - gseq_orphan_models.insert(name.clone()); - } - match tr.ranges.len() { - 0 => gseq_ranges_absent += 1, - _ => { - if tr - .ranges - .iter() - .any(|&(lo, hi)| lo != 0 || hi as usize != tr.ts.len() - 1) - { - gseq_ranges_restrict += 1; - gseq_restrict_models.insert(name.clone()); - } - } - } - } - continue; - } - let eps = if comps == 4 { ROT_EPS } else { VEC_EPS }; - for (slot, &(start, end)) in seqs.iter().enumerate() { - if end <= start { - continue; - } - if tr.ranges.get(slot).is_none() { - ranges_absent += 1; - } - let Some((mine_a, mine_b, was_empty)) = tr.benilla_today(start, end) else { - continue; - }; - let (Some(ref_a), Some(ref_b)) = - (tr.reference(slot, start), tr.reference(slot, end)) - else { - continue; - }; - let d = tr.delta(mine_a, ref_a).max(tr.delta(mine_b, ref_b)); - let peak = if comps == 4 { - &mut peak_rot - } else { - &mut peak_vec - }; - if was_empty { - peak.0 = peak.0.max(d); - slots_empty += 1; - if d > eps { - empty_differ += 1; - m_empty += 1; - worst_empty.push((d, name.clone(), bi, ch, slot)); - } - } else { - peak.1 = peak.1.max(d); - slots_keyed += 1; - // Does this band's own key set sit inside the window the reference - // searches? If not, adopting the window would drop playable keys. - if let Some(&(lo, hi)) = tr.ranges.get(slot) { - let inb: Vec = (0..tr.ts.len()) - .filter(|&k| tr.ts[k] >= start && tr.ts[k] <= end) - .collect(); - if inb.first().is_some_and(|&f| (f as u32) < lo) - || inb.last().is_some_and(|&l| (l as u32) > hi) - { - window_violation += 1; - } - } - if d > eps { - edge_differ += 1; - m_edge += 1; - worst_edge.push((d, name.clone(), bi, ch, slot)); - } - } - } - } - } - // The other half of the check: what our parser ACTUALLY emits. A band-slot whose emitted - // key count exceeds its own in-band key count is one where the head/tail sample differed - // from the edge key and had to be carried — the only slots this parse changes. - for a in benilla_formats::parse_m2_animations(&b) { - for bk in &a.bones { - for (off, comps, emitted) in [ - (0x0c, 3, bk.translation.len()), - (0x28, 4, bk.rotation.len()), - (0x44, 3, bk.scale.len()), - ] { - let brec = bo + bk.bone as usize * 0x6c; - let Some(tr) = RawBoneTrack::read(&b, brec + off, comps) else { - continue; - }; - if tr.gseq != 0xffff { - continue; - } - let inb = tr - .ts - .iter() - .filter(|&&ts| ts >= a.start_ms && ts <= a.end_ms) - .count(); - if emitted > inb.max(1) { - emitted_extra += 1; - emitted_models.insert(name.clone()); - } - } - } - } - if m_step > 0 { - models_step += 1; - step_models.insert(name.clone(), m_step); - } - if m_empty > 0 { - models_empty_differ += 1; - } - if m_edge > 0 { - models_edge_differ += 1; - } - // Keep the worst-offender lists bounded without losing the tail. - for w in [&mut worst_empty, &mut worst_edge] { - if w.len() > 4096 { - w.sort_by(|a, b| b.0.total_cmp(&a.0)); - w.truncate(512); - } - } - if worst_step.len() > 4096 { - worst_step.sort_by(|a, b| b.0.total_cmp(&a.0)); - worst_step.truncate(512); - } - } - worst_empty.sort_by(|a, b| b.0.total_cmp(&a.0)); - worst_edge.sort_by(|a, b| b.0.total_cmp(&a.0)); - println!("scanned {scanned} models · {total_tracks} keyed bone tracks"); - println!("\nbone×channel×sequence slots: {slots_keyed} keyed, {slots_empty} EMPTY"); - println!(" ranges array absent for the slot: {ranges_absent}"); - println!(" band keys OUTSIDE their own ranges window: {window_violation} (must be 0 to adopt the window)"); - for (label, n, models, worst) in [ - ( - "EMPTY band (nearest-key vs the file's window)", - empty_differ, - models_empty_differ, - &worst_empty, - ), - ( - "HELD edge (hold vs the reference's ongoing lerp)", - edge_differ, - models_edge_differ, - &worst_edge, - ), - ] { - println!("\n{label}: {n} slots differ, across {models} models"); - for (d, m, bi, ch, slot) in worst.iter().take(15) { - let unit = if *ch == "rot" { "deg" } else { "u" }; - println!(" {d:9.3}{unit:<4} {m:<52} bone {bi:<4} {ch:<6} slot {slot}"); - } - } - worst_step.sort_by(|a, b| b.0.total_cmp(&a.0)); - println!( - "\nSTEP bone tracks (interp == 0, the reference copies keys[k0]): {step_tracks} of \ - {total_tracks}, in {models_step} models" - ); - println!( - " bands where a step track holds 2+ keys (we glide, the reference snaps): {step_bands}; \ - {} of them snap by more than the noise floor. Worst:", - worst_step.len() - ); - for (d, m, bi, ch, slot, n) in worst_step.iter().take(20) { - let unit = if *ch == "rot" { "deg" } else { "u" }; - println!(" {d:9.3}{unit:<4} {m:<52} bone {bi:<4} {ch:<6} slot {slot:<4} ({n} keys)"); - } - println!( - "\nGLOBAL-SEQUENCE bone tracks: {gseq_tracks} total, {gseq_multi} multi-key (the \ - `GlobalSeqDrive` lane's input; single-key ones are constants folded into every clip)" - ); - println!( - " multi-key on a ZERO-period global sequence — sampled by NEITHER lane: {gseq_orphan}, \ - in {} models", - gseq_orphan_models.len() - ); - for m in gseq_orphan_models.iter().take(10) { - println!(" {m}"); - } - println!( - " their `ranges` window: absent (whole key list) {gseq_ranges_absent}, RESTRICTIVE \ - (not [0, last] — would clip the loop) {gseq_ranges_restrict} in {} models", - gseq_restrict_models.len() - ); - for m in gseq_restrict_models.iter().take(10) { - println!(" {m}"); - } - println!( - "\nworst disagreement anywhere in the corpus (a BOUND, not a threshold count):\n \ - rotation: empty band {:.5} deg, keyed edge {:.5} deg\n \ - translation/scale: empty band {:.6} u, keyed edge {:.6} u", - peak_rot.0, peak_rot.1, peak_vec.0, peak_vec.1 - ); - println!( - "\nEMITTED clips: {emitted_extra} band-slots carry a head/tail sample beyond their own \ - in-band keys, in {} models", - emitted_models.len() - ); - for m in emitted_models.iter().take(10) { - println!(" {m}"); - } - println!(" models with any step track:"); - for (m, n) in step_models.iter().take(10) { - println!(" {n:>6} {m}"); - } - if step_models.len() > 10 { - println!(" … and {} more models", step_models.len() - 10); - } - 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(()) -} - -/// Sweep every `.m2` and census its particle emitters' over-life **flipbook** fields — the -/// population instrument behind decision 0685 (the reverse-playing cell ramp). -/// -/// One line per emitter that is interesting on any axis, then the totals. The axes are exactly the -/// ways a flipbook reader can be wrong, each of which shipped data does exercise: -/// -/// - `INVERTED` — a `begin > end` pair. Legal, and it means *play the sheet backwards*; a reader -/// that clamps into `[begin, end]` mangles it (and in Rust panics outright). -/// - `TAIL-RAMP` — the tail streak's own ramp differs from the head's, on an emitter that draws a -/// tail. The two are independently authored (file +0x168.. vs +0x174..); handing the head's cell -/// to the streak animates it through a sheet the author pinned to one cell. -/// - `PAST-ATLAS` — a cell index beyond `rows·cols`. The reference masks the COLUMN and leaves the -/// ROW unbounded, so the index wraps to row 0 rather than holding the last cell. -/// - `REPEAT` — a per-segment repeat count ≠ 1, i.e. the sheet cycles more than once per segment. -/// - `NON-POW2` / `MID` — the two shapes the reference itself degrades on (a 1×1 fallback, and a -/// `mid` of 0/1 that walks its own sampler into a NaN). Both are empty in 1.12.1 and are here so -/// that stays checkable. -pub fn cellscan(chain: &mut Chain) -> Result<()> { - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| n.to_ascii_lowercase().ends_with(".m2")) - .collect(); - - let (mut models, mut emitters) = (0u32, 0u32); - let (mut inverted, mut tail_ramp, mut repeat_ne1) = (0u32, 0u32, 0u32); - let (mut past_atlas, mut past_atlas_real, mut bad_tiles, mut bad_mid) = - (0u32, 0u32, 0u32, 0u32); - - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - models += 1; - let Ok(defs) = benilla_formats::parse_m2_particle_emitters(&bytes) else { - continue; - }; - for (i, e) in defs.iter().enumerate() { - emitters += 1; - let ol = &e.over_life; - let at = format!("{name} [{i}] {}x{}", e.tile_rows, e.tile_cols); - let ramps = [ - ol.head_cells[0], - ol.head_cells[1], - ol.tail_cells[0], - ol.tail_cells[1], - ]; - let pair = |r: &benilla_formats::CellRamp| (r.begin, r.end); - - if ramps.iter().any(|r| r.begin > r.end) { - inverted += 1; - println!( - "INVERTED {at} head {:?}/{:?}", - pair(&ol.head_cells[0]), - pair(&ol.head_cells[1]) - ); - } - // Only a tail-drawing emitter (particleType 1/2) can show a tail-ramp difference. - if e.head_tail >= 1 && ol.tail_cells != ol.head_cells { - tail_ramp += 1; - println!( - "TAIL-RAMP {at} head {:?}/{:?} tail {:?}/{:?}", - pair(&ol.head_cells[0]), - pair(&ol.head_cells[1]), - pair(&ol.tail_cells[0]), - pair(&ol.tail_cells[1]) - ); - } - if ol.repeat.iter().any(|&r| r != 1.0) { - repeat_ne1 += 1; - println!("REPEAT {at} {:?}", ol.repeat); - } - let atlas = e.tile_rows * e.tile_cols; - if ramps.iter().any(|r| r.begin >= atlas || r.end >= atlas) { - past_atlas += 1; - // On a 1×1 sheet every index resolves to the same texture, so only a real atlas - // can show the wrap. - if atlas > 1 { - past_atlas_real += 1; - println!( - "PAST-ATLAS {at} head {:?}/{:?}", - pair(&ol.head_cells[0]), - pair(&ol.head_cells[1]) - ); - } - } - if !e.tile_rows.is_power_of_two() || !e.tile_cols.is_power_of_two() { - bad_tiles += 1; - println!("NON-POW2 {at}"); - } - if !(ol.mid > 0.0 && ol.mid < 1.0) { - bad_mid += 1; - println!("MID {at} mid {}", ol.mid); - } - } - } - - eprintln!( - "{models} models / {emitters} emitters scanned\n \ - INVERTED {inverted}\n \ - TAIL-RAMP {tail_ramp}\n \ - REPEAT {repeat_ne1}\n \ - PAST-ATLAS {past_atlas} ({past_atlas_real} on a real >1x1 atlas)\n \ - NON-POW2 {bad_tiles}\n \ - MID 0 or 1 {bad_mid}" - ); - Ok(()) -} - -/// Sweep every `.m2` (optionally under a path prefix) and count, per model, the two halves of the -/// **owner-last draw-order** law: the EFFECTS a model authors (particle emitters + ribbon trails) -/// and the TRANSPARENT-pass batches of its own body those effects must draw after (decisions -/// 0719/0721). A model with both is one the rung actually changes; a model with effects and no -/// transparent batch of its own never had the defect and is listed only in the totals. -/// -/// This is the population instrument the two decisions were argued from. "Does this fix anything -/// besides the voidwalker's eyes?" is not a question to answer by naming plausible creatures — -/// it is a count, and the count is what says whether the mechanism closes a class or a case. -pub fn fxordercensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - // Per top-level content family (Creature / Item / Spells / World / …), so the totals say - // WHERE the class lives rather than only how big it is. - let mut family: BTreeMap = BTreeMap::new(); - let (mut scanned, mut with_fx, mut at_risk) = (0u32, 0u32, 0u32); - let mut rungs: BTreeMap = BTreeMap::new(); - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let emitters = benilla_formats::parse_m2_particle_emitters(&bytes) - .map(|e| e.len()) - .unwrap_or(0); - let trails = benilla_formats::m2_ribbon_emitter_count(&bytes); - if emitters + trails == 0 { - continue; - } - with_fx += 1; - // The occluders: batches the renderer puts in the one distance-sorted transparent list. - // `additive` forces that pass whatever the authored blend says (`model_material`), so the - // test mirrors the renderer's rather than reading the blend word alone. - let dir = name.rsplit_once('\\').map_or("", |(d, _)| d); - let subs = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default(); - let occluders: Vec<&benilla_formats::RenderSubmesh> = subs - .iter() - .filter(|s| { - s.additive - || matches!( - s.blend, - benilla_formats::ModelBlend::Blend - | benilla_formats::ModelBlend::Mod - | benilla_formats::ModelBlend::Mod2x - ) - }) - .collect(); - let transparent = occluders.len(); - // The renderer's own bound and the renderer's own rung — not a re-derivation of them. - let reach = benilla_formats::m2_owner_reach(&subs); - let fam = name.split('\\').next().unwrap_or("?").to_string(); - family.entry(fam).or_default().0 += 1; - if transparent == 0 { - continue; // effects, but nothing of its own that could paint over them - } - at_risk += 1; - family - .entry(name.split('\\').next().unwrap_or("?").to_string()) - .or_default() - .1 += 1; - // At placement scale 1 — the survey number; a scaled placement multiplies the reach. - let rung = benilla_formats::owner_last_rung(reach); - *rungs.entry(rung as u32).or_default() += 1; - println!( - "{transparent:>3} transp {emitters:>2} emit {trails:>2} ribb reach {reach:>8.2} \ - rung {rung:>2.0} {name}" - ); - } - eprintln!( - "{scanned} models scanned\n \ - {with_fx} author effects (emitters and/or ribbons)\n \ - {at_risk} of those ALSO author transparent batches of their own — the population the \ - owner-last rung changes" - ); - eprintln!(" by family (with-effects / at-risk):"); - for (fam, (n, risk)) in &family { - eprintln!(" {fam:<24} {n:>5} / {risk:>5}"); - } - eprintln!(" rung distribution (at-risk models, placement scale 1):"); - for (rung, n) in &rungs { - eprintln!(" rung {rung:>2} {n:>5}"); - } - Ok(()) -} - -/// Sweep every `.m2` (optionally under a path prefix) and census the corpus's **3-D model -/// particles**: the emitters whose record carries a geometry-model reference (`+0x18` — a shard -/// instance renders that GEOMETRY model's own submeshes, not a billboard quad). Per OWNER model: -/// the owner-last draw-order rung its shards are stamped with at runtime -/// (`owner_last_rung(m2_owner_reach(owner submeshes))`); per referenced GEOMETRY model: the -/// material family tuples its render submeshes author — (blend, two_sided, additive, -/// no_depth_write, no_depth_test), the exact key the shard's material is built from. -/// -/// This is the **ground truth for the pipeline-warm menagerie's shard rows**: warming a pipeline -/// per (rung × family) cell is only worth its table if the corpus's cells are enumerable, and this -/// census is what enumerates them — the rung histogram sizes the rung axis, the family-tuple set -/// (split by transparent-pass membership, the renderer's own test) sizes the material axis, and -/// the unresolved-path count bounds what the table can never cover. -pub fn shardcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - - // A submesh's material family: the tuple the shard material is keyed by, rendered compactly - // (`Blend+2s+add`), plus its transparent-pass membership — the renderer's own test, where - // `additive` forces the pass whatever the authored blend says (`model_material`). - let fam = |s: &benilla_formats::RenderSubmesh| { - let transparent = s.additive - || matches!( - s.blend, - benilla_formats::ModelBlend::Blend - | benilla_formats::ModelBlend::Mod - | benilla_formats::ModelBlend::Mod2x - ); - let label = format!( - "{:?}{}{}{}{}", - s.blend, - if s.two_sided { "+2s" } else { "" }, - if s.additive { "+add" } else { "" }, - if s.no_depth_write { "+ndw" } else { "" }, - if s.no_depth_test { "+ndt" } else { "" }, - ); - (label, transparent) - }; - - // Each distinct geometry model is read, parsed, and tallied exactly once; the cache keeps its - // (family, transparent) list per submesh for the per-pair lines. `None` = unresolvable. - let mut geo_cache: BTreeMap>> = BTreeMap::new(); - let mut fam_transparent: BTreeMap = BTreeMap::new(); - let mut fam_opaque: BTreeMap = BTreeMap::new(); - let mut rungs: BTreeMap = BTreeMap::new(); - let mut unresolved: Vec = Vec::new(); - let mut parse_failures: Vec<(String, String)> = Vec::new(); - let (mut scanned, mut owners) = (0u32, 0u32); - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - if benilla_formats::parse_m2_particle_emitters(&bytes).is_err() { - continue; - } - // Distinct geometry references, normalized the way the chain resolves model paths - // (lowercase, `\` separators, `.mdx`/`.mdl` → `.m2` — `model_key` mirrors the crate's own - // `model_path`). - let geos: BTreeSet = record_extras(&bytes) - .iter() - .filter_map(|e| e.geometry_model.as_deref()) - .map(|p| model_key(&p.replace('/', "\\"))) - .collect(); - if geos.is_empty() { - continue; - } - owners += 1; - // The rung the owner's shards are stamped with: the renderer's own bound and rung, at - // placement scale 1 (a scaled placement multiplies the reach). - let dir = name.rsplit_once('\\').map_or("", |(d, _)| d); - let owner_subs = - benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default(); - let reach = benilla_formats::m2_owner_reach(&owner_subs); - let rung = benilla_formats::owner_last_rung(reach); - *rungs.entry(rung as u32).or_default() += 1; - for geo in geos { - if !geo_cache.contains_key(&geo) { - let parsed = match chain.read_file(&geo) { - Ok(gb) => { - let gdir = geo.rsplit_once('\\').map_or("", |(d, _)| d); - match benilla_formats::parse_m2_render_submeshes(&gb, gdir, &[]) { - Ok(gsubs) => { - let fams: Vec<(String, bool)> = gsubs.iter().map(&fam).collect(); - for (label, transparent) in &fams { - let tally = if *transparent { - &mut fam_transparent - } else { - &mut fam_opaque - }; - *tally.entry(label.clone()).or_default() += 1; - } - Some(fams) - } - Err(e) => { - parse_failures.push((geo.clone(), e.to_string())); - None - } - } - } - Err(_) => { - unresolved.push(geo.clone()); - None - } - }; - geo_cache.insert(geo.clone(), parsed); - } - let fams = match &geo_cache[&geo] { - Some(fams) if fams.is_empty() => "(no submeshes)".to_string(), - Some(fams) => fams - .iter() - .map(|(l, _)| l.as_str()) - .collect::>() - .into_iter() - .collect::>() - .join(", "), - None => "UNRESOLVED".to_string(), - }; - println!("rung {rung:>2.0} reach {reach:>8.2} {name} -> {geo} [{fams}]"); - } - } - - eprintln!( - "{scanned} models scanned\n \ - {owners} own a geometry-model (3-D shard) emitter\n \ - {} distinct geometry models referenced\n \ - {} geometry paths unresolvable in the chain", - geo_cache.len(), - unresolved.len() - ); - eprintln!(" owner rung histogram (placement scale 1):"); - for (rung, n) in &rungs { - eprintln!(" rung {rung:>2} {n:>5}"); - } - eprintln!(" geometry family tuples — TRANSPARENT pass (submeshes across distinct models):"); - for (label, n) in &fam_transparent { - eprintln!(" {label:<24} {n:>5}"); - } - eprintln!(" geometry family tuples — opaque/mask pass:"); - for (label, n) in &fam_opaque { - eprintln!(" {label:<24} {n:>5}"); - } - if !unresolved.is_empty() { - eprintln!(" unresolvable geometry paths:"); - for p in &unresolved { - eprintln!(" {p}"); - } - } - if !parse_failures.is_empty() { - eprintln!(" geometry parse failures:"); - for (p, e) in &parse_failures { - eprintln!(" {p}: {e}"); - } - } - Ok(()) -} - -/// The terrain MCSH shadow bit at a world position + an ASCII texel neighborhood (`#` shadowed, -/// `.` lit, `?` off-tile/no-chunk). One MCSH texel is `TILE_SIZE/1024` ≈ 0.52 yd; the grid spans -/// ±8 texels so a doodad base sitting one texel from a shadow edge — the 2.5-vs-0.5 intensity -/// cliff — is visible at a glance. -pub fn shadeat(chain: &mut Chain, map: &str, x: f32, y: f32) -> Result<()> { - let tiles = benilla_formats::load_tiles_around(chain, map, x, y, 0) - .with_context(|| format!("loading the tile under ({x}, {y}) on {map}"))?; - let Some((_, tile)) = tiles.first() else { - anyhow::bail!("no tile exists under ({x}, {y}) on {map}"); - }; - let texel = benilla_formats::TILE_SIZE / 1024.0; - let word = |s: Option| match s { - Some(true) => "SHADOWED (doodad sun intensity 0.5)", - Some(false) => "lit (doodad sun intensity 2.5)", - None => "off-tile / no chunk", - }; - println!( - "MCSH at ({x:.2}, {y:.2}): {}", - word(benilla_formats::mcsh_shadowed_at(&tile.chunks, [x, y, 0.0])) - ); - println!( - "neighborhood, texel {texel:.3} yd — rows +X (north) up, cols +Y (west) left; center marked:" - ); - for dx in (-8i32..=8).rev() { - let mut row = String::new(); - for dy in (-8i32..=8).rev() { - let p = [x + dx as f32 * texel, y + dy as f32 * texel, 0.0]; - let mut c = match benilla_formats::mcsh_shadowed_at(&tile.chunks, p) { - Some(true) => '#', - Some(false) => '.', - None => '?', - }; - if dx == 0 && dy == 0 { - c = if c == '#' { 'S' } else { 'O' }; - } - row.push(c); - } - println!("{row}"); - } - Ok(()) -} - -/// Sweep every `.m2` (optionally under a path prefix) and list the emitters whose **file slot 0 is -/// dead while another slot is alive** — the shape that makes a pinned-slot-0 consumer silently -/// render nothing (decision 0760, found on `BlastedLandsLightningbolt01.m2`, B63). -/// -/// The reference samples the **playing** sequence's rate window every frame (wow-re -/// `part-emission-rate-animated.md` §2); a consumer that pins slot 0 instead is only correct while -/// slot 0 carries the emitter's whole story. When an author keys the burst in a *later* variation — -/// a lightning strike that fires on 5 % of arms, an ambient prop with a rare flourish — slot 0 is a -/// flat zero and the pinned consumer emits nothing, for ever, on every placement. That is invisible -/// from the outside: the emitter is built, pooled, and ticking; it just never births a particle. -/// -/// `peak0` is slot 0's own peak rate, `peakN` the best any other slot reaches. A listed emitter has -/// `peak0 <= 0 < peakN`. `slots` counts FILE sequence slots (the axis `EmitTiming` bakes on). -pub fn partslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - let (mut scanned, mut with_emitters, mut emitters, mut dead0) = (0u32, 0u32, 0u32, 0u32); - let mut models = 0u32; - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let Ok(defs) = benilla_formats::parse_m2_particle_emitters(&bytes) else { - continue; - }; - if defs.is_empty() { - continue; - } - with_emitters += 1; - let mut lines = Vec::new(); - for (i, d) in defs.iter().enumerate() { - emitters += 1; - let views = d.timing.slot_views(); - if views.len() < 2 { - continue; // one slot ⇒ nothing for a slot pick to get wrong - } - // Peak of a slot's baked rate keys; an unkeyed slot emits nothing (`None` ⇒ rate 0). - let peak = |s: usize| -> f32 { - views - .get(s) - .and_then(|v| v.1) - .map(|keys| keys.iter().map(|&(_, v)| v).fold(0.0, f32::max)) - .unwrap_or(0.0) - }; - let peak0 = peak(0); - let (mut best, mut best_slot) = (0.0f32, 0usize); - for s in 1..views.len() { - if peak(s) > best { - best = peak(s); - best_slot = s; - } - } - if peak0 <= 0.0 && best > 0.0 { - dead0 += 1; - lines.push(format!( - " emitter {i:>2}: slots {:>2} peak0 {peak0:>7.1} peakN {best:>7.1} \ - @ slot {best_slot} tex {}", - views.len(), - d.texture.as_deref().unwrap_or("NONE"), - )); - } - } - if !lines.is_empty() { - models += 1; - println!("{name}"); - for l in lines { - println!("{l}"); - } - } - } - eprintln!( - "{scanned} models scanned, {with_emitters} with emitters, {emitters} emitter(s): \ - {dead0} DEAD-IN-SLOT-0 across {models} model(s) — these emit nothing at all under a \ - pinned-slot-0 consumer" - ); - Ok(()) -} - -/// Sweep every `.m2` (optionally under a path prefix) and list the batches whose texture is -/// authored **CLAMP** (`M2Texture.flags` bit 0/1 clear) while the batch's own UVs run **outside -/// `0..1`** — the exact population a repeat-sampling renderer draws wrong (decision 0763, B52/B96). -/// -/// The margin outside `0..1` is deliberate authoring: clamped, it samples the texture's transparent -/// border and the card fades out to nothing. Sampled with repeat it wraps into the opposite edge — -/// on a cutout sheet, the opaque middle — so the margin draws as solid geometry with a hard seam -/// where u or v crosses the wrap. That is why a snow-fir grows pale plates with a crease down each -/// bough, and why the artefact never looked like an extra primitive: it is the *same* card, -/// sampling the wrong texels. -/// -/// `over` is how far past the edge the batch reaches, in UV units — the width of the wrongly-drawn -/// margin as a fraction of the sheet. -pub fn uvwrapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - let (mut scanned, mut batches, mut hits, mut models) = (0u32, 0u32, 0u32, 0u32); - let mut cutout_hits = 0u32; - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); - let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else { - continue; - }; - let mut lines = Vec::new(); - for (i, s) in subs.iter().enumerate() { - if s.uvs.is_empty() { - continue; - } - batches += 1; - let ext = |axis: usize| { - s.uvs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), t| { - (lo.min(t[axis]), hi.max(t[axis])) - }) - }; - let (u, v) = (ext(0), ext(1)); - // Only an axis authored CLAMP can be drawn wrong by repeat; a wrapping axis is meant - // to tile. A hair of float slop past the edge is not a margin — require 1/512 of a - // sheet, well under the thinnest authored border and well over rounding. - const SLOP: f32 = 1.0 / 512.0; - let bad_u = !s.wrap_x && (u.0 < -SLOP || u.1 > 1.0 + SLOP); - let bad_v = !s.wrap_y && (v.0 < -SLOP || v.1 > 1.0 + SLOP); - if !bad_u && !bad_v { - continue; - } - hits += 1; - let cutout = matches!( - s.blend, - benilla_formats::ModelBlend::AlphaTest | benilla_formats::ModelBlend::Blend - ); - if cutout { - cutout_hits += 1; - } - let over = [ - (-u.0).max(0.0), - (u.1 - 1.0).max(0.0), - (-v.0).max(0.0), - (v.1 - 1.0).max(0.0), - ] - .into_iter() - .fold(0.0f32, f32::max); - lines.push(format!( - " batch {i:>3}: {:?} {} verts u[{:+.3}..{:+.3}] v[{:+.3}..{:+.3}] \ - over {over:.3} {}{} tex {}", - s.blend, - s.positions.len(), - u.0, - u.1, - v.0, - v.1, - if bad_u { "U" } else { "-" }, - if bad_v { "V" } else { "-" }, - s.texture.as_deref().unwrap_or("NONE"), - )); - } - if !lines.is_empty() { - models += 1; - println!("{name}"); - for l in lines { - println!("{l}"); - } - } - } - eprintln!( - "{scanned} models scanned, {batches} textured batch(es): {hits} CLAMP-AUTHORED BATCHES \ - SAMPLING OUTSIDE 0..1 across {models} model(s) — {cutout_hits} of them cutout/blend, \ - where wrapping changes the silhouette rather than just the colour" - ); - Ok(()) -} - -/// Sweep every `.m2` and report, per texture path, which sampler ADDRESS MODES the corpus asks of -/// it — and how many paths are asked for **more than one** (decision 0763). -/// -/// The design question behind it: the address mode lives on the GPU sampler, which in our asset -/// layer is a property of the loaded `Image`, which is keyed by path. If a `.blp` is only ever -/// asked for one mode, path-keying stays correct and the mode can simply ride the load. Every path -/// asked for two needs two uploads, or one of its users renders wrong. -pub fn texmodescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - // texture path -> set of (wrap_x, wrap_y) asked for, as a 4-bit mask - let mut modes: std::collections::BTreeMap = std::collections::BTreeMap::new(); - let mut scanned = 0u32; - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); - let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else { - continue; - }; - for s in &subs { - let Some(tex) = s.texture.as_deref() else { - continue; - }; - let bit = 1u8 << ((s.wrap_x as u8) | ((s.wrap_y as u8) << 1)); - *modes.entry(tex.to_ascii_lowercase()).or_default() |= bit; - } - } - let mut conflicted = 0u32; - for (path, mask) in &modes { - if mask.count_ones() > 1 { - conflicted += 1; - let want = |b: u8, s: &'static str| if mask & (1 << b) != 0 { s } else { "" }; - println!( - "CONFLICT {path} asked as: {}{}{}{}", - want(0, "[clamp,clamp] "), - want(1, "[repeat,clamp] "), - want(2, "[clamp,repeat] "), - want(3, "[repeat,repeat] "), - ); - } - } - eprintln!( - "{scanned} models scanned, {} distinct texture path(s): {conflicted} asked for MORE THAN \ - ONE address mode (each needs its own upload, or one of its users renders wrong)", - modes.len() - ); - Ok(()) -} - -/// Sweep every WMO **root** in the chain and report the two halves of the skybox mechanism: which -/// roots author a **MOSB** skybox model, and which carry groups flagged `0x40000` -/// ([`benilla_formats::WmoGroupInfo::show_skybox`]). -/// -/// This is the instrument that *identifies* the flag — and **exactly how far that identification -/// reaches is the point**. `0x40000` is undocumented, so the cross-tab is what establishes it means -/// anything at all: the bit never appears on a group whose root names no skybox, across all 815 -/// roots. That is a one-way implication, `flag ⇒ MOSB`, and the summary prints it so the claim is -/// re-checkable in one command rather than trusted from a decision record. -/// -/// **It does not, and cannot, say which group the RENDERER tests** — and reading it as if it did is -/// the mistake decision 0767 made (superseded by 0773). The carved law is that `0x40000` is tested -/// inside the portal flood (`0x6b42e0` in `0x6b41c0`) on the group being *visited*, so the predicate -/// is "any flood-reached group carries the bit". A census over static asset bytes has no way to see -/// that distinction; only the binary did. -/// -/// It is also the population instrument for the mechanism: which buildings in 1.12 replace the -/// `Light.dbc` gradient dome with an authored sky, and how much of each one does it. Stratholme's -/// city shell sets the bit on 61 of its 83 groups; the only other roots that set it at all are the -/// four Caverns of Time shells, which ship in the 5875 data with no 1.12 instance to enter. -pub fn skyboxscan(chain: &mut Chain) -> Result<()> { - // Roots only: a group file is `_NNN.wmo`, and only the root carries MOSB/MOGI. - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".wmo") - && !l - .strip_suffix(".wmo") - .and_then(|s| s.rsplit('_').next()) - .is_some_and(|tail| tail.len() == 3 && tail.bytes().all(|b| b.is_ascii_digit())) - }) - .collect(); - - // The cross-tab that identifies the flag: roots with/without a MOSB × groups with/without 0x40000. - let (mut both, mut mosb_only, mut flag_only, mut neither) = (0u32, 0u32, 0u32, 0u32); - let mut scanned = 0u32; - let mut hits: Vec<(String, String, usize, usize)> = Vec::new(); - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - let Ok(root) = benilla_formats::parse_wmo_root(&bytes) else { - continue; // a group file that slipped the name filter, or a truncated root - }; - scanned += 1; - let groups = root.group_infos(); - let flagged = groups.iter().filter(|g| g.show_skybox).count(); - match (root.skybox(), flagged > 0) { - (Some(sky), true) => { - both += 1; - hits.push((name, sky.to_string(), flagged, groups.len())); - } - (Some(sky), false) => { - mosb_only += 1; - hits.push(( - name, - format!("{sky} (NO group sets 0x40000)"), - 0, - groups.len(), - )); - } - (None, true) => { - flag_only += 1; - hits.push((name, "(no MOSB)".into(), flagged, groups.len())); - } - (None, false) => neither += 1, - } - } - - hits.sort(); - println!("{:<62} {:>7} skybox model", "WMO root", "groups"); - for (name, sky, flagged, total) in &hits { - println!("{name:<62} {flagged:>3}/{total:<3} {sky}"); - } - println!(); - println!("{scanned} WMO root(s) scanned"); - println!(" MOSB skybox AND >=1 group with 0x40000 : {both}"); - println!(" MOSB skybox but NO group with 0x40000 : {mosb_only}"); - println!(" group(s) with 0x40000 but NO MOSB : {flag_only}"); - println!(" neither : {neither}"); - if flag_only == 0 { - println!( - "\n=> 0x40000 NEVER appears without a MOSB ({flag_only} counter-examples in {scanned} \ - roots), and {mosb_only} root(s) name a skybox no group asks for. So the bit is real and \ - both halves matter — but this census establishes only 'flag implies MOSB'." - ); - println!( - " It does NOT say WHICH group the renderer tests, and reading it as if it did is the \ - mistake decision 0767 made (superseded by 0773). The carved law: 0x40000 is tested \ - inside the portal flood (0x6b42e0 in 0x6b41c0) on the group being VISITED, never on the \ - group the camera stands in — so the predicate is 'any FLOOD-REACHED group carries the \ - bit, and the root names a MOSB'. Stratholme's King's Square is the counter-example that \ - settles it: the camera's own group (39) is EXTERIOR and unflagged, and the reference \ - paints the sky there anyway." - ); - } else { - println!( - "\n=> {flag_only} group(s) set 0x40000 with no MOSB to draw — that would break even the \ - weak 'flag implies MOSB' reading this census rests on; re-derive before building on it." - ); - } - Ok(()) -} - -/// `AnimationData.dbc` ids of the effect-model lifecycle triple (names read from the real DBC). -const ANIM_STAND: u16 = 0; -const ANIM_HOLD: u16 = 158; -const ANIM_DECAY: u16 = 159; - -/// Sweep every `.m2` (optionally under a path prefix) and census the **effect-model animation -/// lifecycle**: which models author the `Stand`(0) → `Hold`(158) → `Decay`(159) triple, and what a -/// consumer that arms ONE sequence and never advances would render for each. -/// -/// The reference arms an effect instance's default track — `animationLookup[0]`, i.e. `Stand` — and -/// a model that also owns `Hold` authors `Stand` as a **birth** (grow-in, clamp flag set) with the -/// sustained pulse living in the separate looping `Hold` sequence, and the fade-out in `Decay`. -/// A one-sequence consumer therefore FREEZES on the last frame of the birth for the effect's whole -/// life — no pulse, no fade — which is exactly what "the Ice Barrier shield is frozen" looks like. -/// -/// The classification is that failure, made countable: -/// - **FREEZE** — owns `Hold`, and the armed `Stand` clamps: the reference pulses, we hold a pose. -/// - **hold-loops** — owns `Hold` but `Stand` itself loops: still wrong (the wrong clip), but moving. -/// - **decay-only** — owns `Decay` and no `Hold`: only the reap leg is unrendered. -/// -/// It also counts the **file-order divergence**: the reference arms `animationLookup[0]`, so a -/// consumer that arms the file's *first slot* instead is additionally wrong on any model whose slot -/// 0 is not its `Stand` (the `DuelingFlag.m2` Spawn/Stand/Despawn shape, decision 0637). The two -/// bugs are independent and the closing line separates them. -/// -/// The population instrument for the mechanism, so it is closed corpus-wide rather than spell by -/// spell; `m2seq` then explains one model in full. -pub fn fxlifescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - let (mut scanned, mut freeze, mut hold_loops, mut decay_only) = (0u32, 0u32, 0u32, 0u32); - let (mut no_stand, mut slot0_not_stand) = (0u32, 0u32); - let mut by_dir: BTreeMap = BTreeMap::new(); - let mut rows: Vec<(String, String, String, f32, f32, f32)> = Vec::new(); - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let seqs = benilla_formats::parse_m2_animations(&bytes); - if seqs.is_empty() { - continue; - } - // What the reference arms: `animationLookup[0]` — Stand — never the file-order-first slot - // (they diverge on any model whose sequence 0 is not its Stand). - let armed = seqs.iter().find(|s| s.anim_id == ANIM_STAND); - let hold = seqs.iter().find(|s| s.anim_id == ANIM_HOLD); - let decay = seqs.iter().find(|s| s.anim_id == ANIM_DECAY); - if hold.is_none() && decay.is_none() { - continue; // neither leg authored — no lifecycle to miss - } - let class = match (hold.is_some(), armed) { - (true, Some(a)) if !a.looping => { - freeze += 1; - "FREEZE" - } - (true, _) => { - hold_loops += 1; - "hold-loops" - } - (false, _) => { - decay_only += 1; - "decay-only" - } - }; - // The file-order divergence, independent of the lifecycle bug: what a slot-0 consumer arms - // versus the reference's `animationLookup[0]`. - let arm = match (armed, seqs[0].anim_id) { - (None, first) => { - no_stand += 1; - format!("no-Stand(slot0={first})") - } - (Some(_), ANIM_STAND) => "slot0".to_string(), - (Some(_), first) => { - slot0_not_stand += 1; - format!("slot0={first}!") - } - }; - let top = name.split_once('\\').map(|(d, _)| d).unwrap_or(""); - *by_dir.entry(top.to_ascii_lowercase()).or_default() += 1; - rows.push(( - name, - class.to_string(), - arm, - armed.map_or(0.0, |a| a.duration), - hold.map_or(0.0, |s| s.duration), - decay.map_or(0.0, |s| s.duration), - )); - } - rows.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0))); - println!( - "model class arm stand hold decay" - ); - for (name, class, arm, stand, hold, decay) in rows.iter().take(80) { - println!("{name:<60} {class:<10} {arm:<10} {stand:>5.2} {hold:>5.2} {decay:>5.2}"); - } - if rows.len() > 80 { - println!("… and {} more", rows.len() - 80); - } - println!( - "\n{} of {scanned} models author a Hold(158)/Decay(159) lifecycle leg", - rows.len() - ); - println!(" FREEZE {freeze:>5} (owns Hold; the armed Stand clamps — a pose, where the reference pulses)"); - println!(" hold-loops {hold_loops:>5} (owns Hold; the armed Stand loops — moving, but the wrong clip)"); - println!(" decay-only {decay_only:>5} (owns Decay only — just the reap leg unrendered)"); - println!( - "of those, the file-order divergence (a slot-0 consumer arms the wrong sequence outright): \ - {slot0_not_stand} with slot 0 != Stand, {no_stand} with no Stand at all" - ); - // Named in full, never left to the row cap: this set is small and each member is a distinct - // "arms the wrong sequence from frame one" case. - for (name, _, arm, ..) in rows.iter().filter(|r| r.2 != "slot0") { - println!(" {name:<58} {arm}"); - } - println!("by top-level directory:"); - for (dir, n) in &by_dir { - println!(" {dir:<16} {n:>5}"); - } - Ok(()) -} - -/// Sweep every `.m2` (optionally under a path prefix) and list the models where the **loader-idle -/// sequence is not file slot 0** while benilla's render content gate declines to arm it — so every -/// per-sequence bake (`EmitTiming`/`EmitParams`/`AlphaAnim`) degrades to slot 0, a sequence the -/// instance is not playing (decision 0936, found on the Stormwind battlefield banner). -/// -/// The reference arms the loader-idle sequence on **every** M2 instance at load (`0x70ebd0`'s tail, -/// wow-re `gameobject-anim-arm.md` §1), so "which sequence is this playing" always has an answer. -/// benilla skips the arm when looping the idle would render identically to the static mesh — sound -/// for the *mesh*, and silently wrong for anything keyed on the sequence *identity*. The two only -/// disagree visibly when the idle is not slot 0, which is the `DuelingFlag` shape: -/// `Spawn(145) / Stand(0) / Despawn(157)`, where slot 0 is the **Spawn flourish**. -/// -/// Reports, per model: the emitters whose enabled/rate gate at t = 0 differs between slot 0 and the -/// idle slot (`FX`), and the batches whose combined material-alpha factor differs (`ALPHA`). -pub fn idleslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - let (mut scanned, mut unarmed, mut window) = (0u32, 0u32, 0u32); - let (mut fx_models, mut alpha_models) = (0u32, 0u32); - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - let anims = benilla_formats::parse_m2_animations(&bytes); - if anims.is_empty() { - continue; - } - scanned += 1; - let idle_id = benilla_formats::parse_m2_playable_animation_lookup(&bytes) - .unwrap_or_default() - .first() - .map_or(0, |p| p.resolved_id); - // benilla's render content gate (`benilla_assets`' `idle_pose_differs`): a bind-pose idle - // is not armed at all, so nothing ever overrides the per-sequence bakes' opening slot. - if anims - .iter() - .any(|a| a.anim_id == idle_id && !a.is_rest_pose()) - { - continue; - } - unarmed += 1; - let idle = anims - .iter() - .find(|a| a.anim_id == idle_id) - .map_or(0, |a| a.seq_index); - if idle == 0 { - continue; // degrading to slot 0 happens to BE the idle slot — no divergence - } - window += 1; - let mut lines = Vec::new(); - let defs = benilla_formats::parse_m2_particle_emitters(&bytes).unwrap_or_default(); - let mut fx = 0u32; - for (i, d) in defs.iter().enumerate() { - let on = |s: usize| { - d.timing.emitting(Some(s), 0.0, 0.0) && d.timing.rate(Some(s), 0.0, 0.0) > 0.0 - }; - if on(0) != on(idle) { - fx += 1; - lines.push(format!( - " FX emitter {i:>2}: slot 0 {:>3}, idle slot {idle} {:>3} tex {}", - if on(0) { "ON" } else { "off" }, - if on(idle) { "ON" } else { "off" }, - d.texture.as_deref().unwrap_or("NONE"), - )); - } - } - let dir = name.rsplit_once('\\').map_or("", |(d, _)| d); - let subs = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default(); - let mut alpha = 0u32; - for (i, s) in subs.iter().enumerate() { - let Some(a) = &s.alpha_anim else { continue }; - let (f0, fi) = (a.sample(Some(0), 0.0, 0.0), a.sample(Some(idle), 0.0, 0.0)); - if (f0 - fi).abs() > 1e-3 { - alpha += 1; - lines.push(format!( - " ALPHA batch {i:>2}: slot 0 {f0:.2}, idle slot {idle} {fi:.2}" - )); - } - } - if !lines.is_empty() { - fx_models += u32::from(fx > 0); - alpha_models += u32::from(alpha > 0); - println!("{name} (idle slot {idle} of {} sequences)", anims.len()); - for l in lines { - println!("{l}"); - } - } - } - eprintln!( - "{scanned} models with sequences, {unarmed} whose idle the content gate leaves unarmed, \ - {window} of those with idle slot != 0 (the divergence window): \ - {fx_models} model(s) whose EMITTERS gate differently, \ - {alpha_models} whose MATERIAL ALPHA differs" - ); - Ok(()) -} - -/// Sweep every `.m2` (optionally under a path prefix) and list the models whose animation is -/// authored **entirely outside the bone tracks** — sequences exist, per-sequence consumers exist -/// (particle emitters, ribbons, material alpha/colour, UV transforms), and **not one sequence -/// keys a bone**. -/// -/// That combination is the blind spot of a renderer whose sequence clock rides a *bone* animation -/// clip: with no keyed bone there is no clip, so there is no player, so nothing ever says which -/// sequence is playing or how far into it — and every per-sequence consumer degrades to "file slot -/// 0, at t = 0", permanently. The emitters still build, pool and tick; they just read the wrong -/// column of a table that never advances. Found on the Molten Core rune + flame ring (decision -/// 0941): the rune's slot 0 is its *Closed* band, where every emission rate key is 0 (no flames at -/// all), and the ring's spline spawn-window opens `0 → 1` over its first second, so frozen at t=0 -/// all 180 particles are born at one point of a 2.8-yd circle — one bright blob. -/// -/// The `[GO]` mark is the cross-tab that matters: a `GameObjectDisplayInfo` model reaches the -/// world through the **hosted** clock (`EmitClock::Host`), which is the lane that freezes at -/// `t = 0`. A placed doodad of the same shape still runs its spawn-age clock (only its *slot* is -/// pinned — decision 0760's axis), so it is wrong in a different, milder way. -/// -/// PARTIAL models — some sequences key bones, some don't — are tallied, not listed: there the -/// clock exists, but the unkeyed sequences are unreachable (no clip to arm), so a GameObject -/// substate or a creature animation id that resolves to one plays nothing. -pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { - // Which models a GameObject can display — the hosted-clock lane. - let go_models: HashSet = benilla_formats::load_gameobject_catalog(chain) - .map(|c| c.iter().map(|(_, p)| model_key(p)).collect()) - .unwrap_or_default(); - let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); - let names: Vec = chain - .list() - .context("listing chain contents")? - .into_iter() - .map(|e| e.name) - .filter(|n| { - let l = n.to_ascii_lowercase(); - l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) - }) - .collect(); - let (mut scanned, mut with_seqs, mut frozen, mut frozen_go, mut partial, mut inert) = - (0u32, 0u32, 0u32, 0u32, 0u32, 0u32); - let mut frozen_emitters = 0usize; - for name in names { - let Ok(bytes) = chain.read_file(&name) else { - continue; - }; - scanned += 1; - let seqs = benilla_formats::parse_m2_animations(&bytes); - if seqs.is_empty() { - continue; - } - with_seqs += 1; - // Exactly the runtime's own test (`build_animation_clip`): a sequence becomes a clip iff - // some bone track produced a key inside its band. - let keyed = seqs - .iter() - .filter(|a| { - a.bones.iter().any(|b| { - !b.translation.is_empty() || !b.rotation.is_empty() || !b.scale.is_empty() - }) - }) - .count(); - // Counted over EVERY model with sequences, consumers or not: it measures clip - // REACHABILITY (can an arm find this sequence at all), which is a property of the model. - if keyed > 0 && keyed < seqs.len() { - partial += 1; - } - let Ok(s) = benilla_formats::parse_m2_animation_summary(&bytes) else { - continue; - }; - // The per-sequence consumers: everything that samples a track on the playing sequence's - // clock. A constant (≤1 key) colour/alpha track is excluded — it reads the same in every - // slot, so a frozen clock costs it nothing. - let consumers = s.particle_emitter_count - + s.ribbon_emitter_count - + s.color_alpha_tracks.1 - + s.color_rgb_tracks.1 - + s.transparency_tracks.1 - + s.texture_transform_count; - if keyed == 0 && consumers == 0 { - // The same shape with nothing to sample: a clock costs it nothing and buys it - // nothing. Counted because it IS the cost side of giving every sequenced model a - // clock — these are the models that gain an inert one. - inert += 1; - } - if consumers > 0 && keyed == 0 { - frozen += 1; - frozen_emitters += s.particle_emitter_count; - let is_go = go_models.contains(&name.to_ascii_lowercase()); - if is_go { - frozen_go += 1; - } - println!( - "{}{name}\n seqs {:>3} (0 keyed) · emitters {:>2} ribbons {} · alpha {} rgb {} \ - transp {} · uvanim {} · gseq {}", - if is_go { "[GO] " } else { " " }, - seqs.len(), - s.particle_emitter_count, - s.ribbon_emitter_count, - s.color_alpha_tracks.1, - s.color_rgb_tracks.1, - s.transparency_tracks.1, - s.texture_transform_count, - s.global_seq_channels.len(), - ); - } - } - eprintln!( - "{scanned} models scanned, {with_seqs} with sequences: {frozen} CLOCKLESS \ - ({frozen_emitters} emitters) — sequences + per-sequence consumers, not one keyed bone; \ - {frozen_go} of them GameObject display models (the hosted lane, frozen at slot 0 t=0). \ - {partial} PARTIAL models have unkeyed sequences a clip lookup can never reach; \ - {inert} more are boneless with nothing to sample (a clock there is inert)." - ); - Ok(()) -} diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/geometry.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/geometry.rs new file mode 100644 index 00000000..6bdb98ab --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/geometry.rs @@ -0,0 +1,484 @@ +//! Corpus scans over **what geometry a model draws** — the shape of its batches, independent of +//! how they are textured or shaded. +//! +//! Billboard cards and which way they face (`bbscan`, `bbfacescan`), geosets and the untextured / +//! single-triangle strays (`geosetscan`), and flat ground-plane quads (`groundscan`). What +//! MATERIAL a batch carries is [`super::material`]'s question. + +use std::collections::{BTreeMap, HashMap}; + +use anyhow::Result; +use benilla_formats::Chain; + +/// Sweep every `.m2` (under `prefix`, if given) and classify every BILLBOARD batch by which way its +/// geometry faces — see the `Bbfacescan` command doc for why the sign decides visibility. +/// +/// A billboard bone puts the model's **+X** toward the viewer (`billboard-bone-law`, spherical arm), +/// so a batch whose winding normal is +X faces the camera and a −X one faces away. Single-sided +/// (`two_sided` false, i.e. no material `0x04`), the away-facing ones are backface-culled by the +/// reference from every angle — they are authored placeholders the author never saw. +pub fn bbfacescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + let (mut scanned, mut cards) = (0u32, 0u32); + // The four card populations. `away_single` is the one the renderer's forced-two-sided override + // changes: those and only those become visible when a card is not allowed to be culled. + let (mut toward, mut away_single, mut away_two, mut edge_on) = (0u32, 0u32, 0u32, 0u32); + // …and the batches that are not cards at all. A facing only exists for a batch that IS one + // plane; a closed solid has faces every way round, backface culling never hides it, and + // sampling its first triangle answers a question it doesn't have (see `plane_normal`). + let mut solid = 0u32; + // …and the other side of the same gate: batches the split REFUSED because their geometry is + // welded to a billboard bone (`RenderSubmesh::welded_billboard`, decisions 0839/0841). These + // carry no `billboard` at all, so the card loop below never sees them — they are counted from + // the flag the render lanes read, which is what makes this a cross-check of `bbscan`'s SEAM + // column (same models, counted from the two ends of the rule) rather than a re-derivation. + let (mut welded, mut weld_models) = (0u32, 0u32); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); + let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else { + continue; + }; + let mut lines = Vec::new(); + let weld = subs.iter().filter(|s| s.welded_billboard).count() as u32; + welded += weld; + weld_models += u32::from(weld > 0); + for (i, s) in subs.iter().enumerate() { + let Some(bb) = &s.billboard else { continue }; + cards += 1; + // Not one plane ⇒ not a card. Counted apart rather than classified by a triangle. + if s.plane_normal().is_none() { + solid += 1; + continue; + } + // The winding normal's X component: +1 faces the viewer, −1 faces away. + let Some(fx) = facet_x(s) else { + edge_on += 1; + continue; + }; + if fx > 0.5 { + toward += 1; + } else if fx < -0.5 { + if s.two_sided { + away_two += 1; + } else { + away_single += 1; + lines.push(format!( + " batch {i:>3}: {:?} {:?} {} verts facetX {fx:+.2} tex {}", + bb.kind, + s.blend, + s.positions.len(), + s.texture.as_deref().unwrap_or("NONE"), + )); + } + } else { + edge_on += 1; + } + } + if !lines.is_empty() { + println!("{name}"); + for l in lines { + println!("{l}"); + } + } + } + eprintln!( + "{scanned} models scanned, {cards} billboard batch(es): {toward} toward, \ + {away_single} away+single-sided (reference culls these), {away_two} away+two-sided, \ + {edge_on} edge-on/degenerate, {solid} NOT PLANAR (3-D geometry — no facing to decide); \ + plus {welded} WELDED batch(es) across {weld_models} model(s) the split refused \ + (skinned, not carded)" + ); + Ok(()) +} + +/// The X component of a batch's first-triangle winding normal, in WoW model space — `None` when the +/// triangle is degenerate or the normal lies in the YZ plane (nothing to decide a facing from). +fn facet_x(s: &benilla_formats::RenderSubmesh) -> Option { + let tri = s.indices.get(..3)?; + let p = |i: u32| s.positions.get(i as usize).copied(); + let (a, b, c) = (p(tri[0])?, p(tri[1])?, p(tri[2])?); + let (u, v) = ( + [b[0] - a[0], b[1] - a[1], b[2] - a[2]], + [c[0] - a[0], c[1] - a[1], c[2] - a[2]], + ); + let n = [ + u[1] * v[2] - u[2] * v[1], + u[2] * v[0] - u[0] * v[2], + u[0] * v[1] - u[1] * v[0], + ]; + let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt(); + (len > 1e-9).then(|| n[0] / len) +} + +/// Sweep every `.m2` (under `prefix`, if given) and classify its billboard usage — see the +/// `Bbscan` command doc. Output per model: the authored arms and how many vertices ride each +/// DIRECTLY (primary bone is the billboard bone — the card path) vs INHERITED (primary bone +/// descends from one — the joint-palette path, decision 0205), then the same question for the +/// model's **particle emitters and ribbons** (`fx[…]`) — the population behind decision 0813: an +/// emitter on (or under) a billboard bone has a camera-dependent origin, because the reference +/// folds the record position through the *replaced* palette matrix +/// (wow-re `part-anchoring-live-bone.md` §1 row 3 · `m2emitspine::particle_bone_xform`). +pub fn bbscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + let arm = |k: benilla_formats::BillboardKind| match k { + benilla_formats::BillboardKind::Spherical => "S", + benilla_formats::BillboardKind::LockX => "X", + benilla_formats::BillboardKind::LockY => "Y", + benilla_formats::BillboardKind::LockZ => "Z", + }; + let (mut scanned, mut hits) = (0u32, 0u32); + // Corpus totals: models exercising each arm, split by how the geometry rides it. + let mut direct_models: HashMap<&'static str, u32> = HashMap::new(); + let mut inherited_models: HashMap<&'static str, u32> = HashMap::new(); + // …and the effect riders (particles/ribbons on a billboard chain). + let mut fx_models = 0u32; + let mut fx_total: HashMap = HashMap::new(); + // …and the seam population (see the classification below). + let (mut seam_models, mut seam_bones) = (0u32, 0u32); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let Ok(fmt) = benilla_m2::parse_m2(&mut std::io::Cursor::new(bytes.as_slice())) else { + continue; + }; + let m = fmt.model(); + let kinds: Vec> = m + .bones + .iter() + .map(|b| benilla_formats::BillboardKind::from_bone_flags(b.flags.bits())) + .collect(); + if kinds.iter().all(|k| k.is_none()) { + continue; + } + hits += 1; + // Nearest billboard ancestor (self included) per bone — the arm whose palette + // replacement a vertex on this bone inherits. Bounded walk (M2 parents precede + // children; the bound is just a malformed-file guard). + let ancestor_arm = |mut i: usize| -> Option<(bool, benilla_formats::BillboardKind)> { + let mut hops = 0; + loop { + if let Some(k) = kinds.get(i).copied().flatten() { + return Some((hops == 0, k)); + } + let p = usize::try_from(*m.bones.get(i).map(|b| &b.parent)?).ok()?; + hops += 1; + if p >= m.bones.len() || hops > m.bones.len() { + return None; + } + i = p; + } + }; + let (mut direct, mut inherited): (HashMap<&str, u32>, HashMap<&str, u32>) = + (HashMap::new(), HashMap::new()); + for v in &m.vertices { + match ancestor_arm(v.bone_indices[0] as usize) { + Some((true, k)) => *direct.entry(arm(k)).or_default() += 1, + Some((false, k)) => *inherited.entry(arm(k)).or_default() += 1, + None => {} + } + } + // SEAM bones: billboard bones whose geometry is welded to the rest of the model — by a + // partial vertex weight, or by a triangle it shares with a static neighbour. The reference + // skins per vertex, so such geometry BENDS (a flap's root stays on the body while its tip + // swings to the camera); a rigid card cannot express that at all, and moving the group + // rigidly tears the flap in two. These are the bones the split declines (decision 0839), + // read through the renderer's own predicate so this census cannot drift from it. + let seam = benilla_formats::non_separable_billboard_bones(&bytes); + let fmt_counts = |m: &HashMap<&str, u32>| { + let mut v: Vec = m.iter().map(|(k, n)| format!("{k}:{n}")).collect(); + v.sort(); + v.join(" ") + }; + // The EFFECT riders: a particle emitter / ribbon whose bone chain reaches a billboard + // bone has a camera-dependent live frame (its record position rides the replaced palette + // matrix), so a consumer that places it at the rest pose puts it in the wrong place. + // `d` = the effect's own bone is the billboard bone, `i` = it descends from one. + let mut fx: HashMap = HashMap::new(); + let mut tally = |tag: &str, bone: u16| { + if let Some((direct, k)) = ancestor_arm(bone as usize) { + let key = format!("{tag}{}{}", if direct { "d" } else { "i" }, arm(k)); + *fx.entry(key).or_default() += 1; + } + }; + for e in benilla_formats::parse_m2_particle_emitters(&bytes) + .unwrap_or_default() + .iter() + { + tally("p", e.bone); + } + for r in benilla_formats::parse_m2_ribbon_emitters(&bytes) + .unwrap_or_default() + .iter() + { + tally("r", r.bone); + } + let fx_counts = { + let mut v: Vec = fx.iter().map(|(k, n)| format!("{k}:{n}")).collect(); + v.sort(); + v.join(" ") + }; + let bones: String = kinds.iter().flatten().map(|&k| arm(k)).collect(); + let seam_col = if seam.is_empty() { + String::new() + } else { + seam_models += 1; + seam_bones += seam.len() as u32; + format!( + " SEAM[{}]", + seam.iter() + .map(u16::to_string) + .collect::>() + .join(",") + ) + }; + println!( + "{bones:>4} direct[{}] inherited[{}] fx[{fx_counts}]{seam_col} {name}", + fmt_counts(&direct), + fmt_counts(&inherited) + ); + if !fx.is_empty() { + fx_models += 1; + for (k, n) in &fx { + *fx_total.entry(k.clone()).or_default() += n; + } + } + for k in direct.keys() { + *direct_models + .entry(match *k { + "S" => "S", + "X" => "X", + "Y" => "Y", + _ => "Z", + }) + .or_default() += 1; + } + for k in inherited.keys() { + *inherited_models + .entry(match *k { + "S" => "S", + "X" => "X", + "Y" => "Y", + _ => "Z", + }) + .or_default() += 1; + } + } + let tot = |m: &HashMap<&'static str, u32>| { + let mut v: Vec = m.iter().map(|(k, n)| format!("{k}:{n}")).collect(); + v.sort(); + v.join(" ") + }; + let fx_tot = { + let mut v: Vec = fx_total.iter().map(|(k, n)| format!("{k}:{n}")).collect(); + v.sort(); + v.join(" ") + }; + eprintln!( + "{scanned} models scanned, {hits} with billboard bones; models by arm — direct(card) [{}] inherited(palette) [{}]; {fx_models} with EFFECTS on a billboard chain [{fx_tot}]; {seam_models} with SEAM billboard bones ({seam_bones} total) — geometry welded to the model, which a rigid card tears", + tot(&direct_models), + tot(&inherited_models) + ); + Ok(()) +} + +/// Sweep every `.m2` (under `prefix`, if given) and census the **geometry a non-character model +/// draws that the reference may not** — the population instrument behind the bug channel's +/// "stray untextured primitive" family. Three independent signals per model, all read through the +/// renderer's own batch resolution (`m2batch`'s), so the report can't drift from the mechanism: +/// +/// - **MULTI-GEOSET** — more than one distinct `skinSectionId`. The character compositor selects +/// among these; every other spawn path draws **all** of them, so this is exactly the population +/// an unfiltered creature/doodad/effect draw over-renders (`Creature\Banshee\Banshee.m2` is the +/// pinned case: `0`×17 + `402`×9). +/// - **UNTEX** — batches with no texture *and* no runtime slot that fills one: neither a character +/// composite slot ([`benilla_formats::RenderSubmesh::char_slot`]) nor a creature skin variation +/// ([`benilla_formats::RenderSubmesh::skin_slot`], filled at spawn from `CreatureDisplayInfo`). +/// Both fills are ordinary, so counting them would drown the signal — 324 of 420 `Creature\` +/// models carry a skin slot. What is left is geometry nothing can texture. +/// - **TINY** — batches of at most 2 faces: the literal single-triangle/quad primitives. +/// +/// A model is listed when it trips any of the three. `m2batch` then explains one model in full. +pub fn geosetscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + let (mut scanned, mut hits) = (0u32, 0u32); + let (mut multi_models, mut untex_models, mut tiny_models) = (0u32, 0u32, 0u32); + // Top-level directory → multi-geoset model count, so the report says *where* the population + // lives (Creature/, Spells/, World/…) rather than only how big it is. + let mut by_dir: BTreeMap = BTreeMap::new(); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); + let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else { + continue; + }; + let mut geosets: BTreeMap = BTreeMap::new(); + let (mut untex, mut tiny) = (0u32, 0u32); + for s in &subs { + *geosets.entry(s.geoset_id).or_default() += 1; + if s.texture.is_none() && s.char_slot.is_none() && s.skin_slot.is_none() { + untex += 1; + } + if !s.indices.is_empty() && s.indices.len() <= 6 { + tiny += 1; + } + } + let multi = geosets.len() > 1; + if !multi && untex == 0 && tiny == 0 { + continue; + } + hits += 1; + if multi { + multi_models += 1; + let top = name.split_once('\\').map(|(d, _)| d).unwrap_or(""); + *by_dir.entry(top.to_ascii_lowercase()).or_default() += 1; + } + if untex > 0 { + untex_models += 1; + } + if tiny > 0 { + tiny_models += 1; + } + let hist = geosets + .iter() + .map(|(id, n)| format!("{id}×{n}")) + .collect::>() + .join(" "); + let mut marks = Vec::new(); + if multi { + marks.push(format!("MULTI-GEOSET({})", geosets.len())); + } + if untex > 0 { + marks.push(format!("UNTEX({untex})")); + } + if tiny > 0 { + marks.push(format!("TINY({tiny})")); + } + println!( + "{:>3} batches [{hist}] {} {name}", + subs.len(), + marks.join(" ") + ); + } + let dirs = by_dir + .iter() + .map(|(d, n)| format!("{d}={n}")) + .collect::>() + .join(" "); + eprintln!( + "{scanned} models scanned, {hits} listed — {multi_models} MULTI-GEOSET, \ + {untex_models} with UNTEX batches, {tiny_models} with TINY batches; \ + multi-geoset by top dir: [{dirs}]" + ); + Ok(()) +} + +/// Sweep every `.m2` (under `prefix`, if given) and report models that author flat ground-plane +/// render geometry — the population instrument for the class of spell effects that lie in the +/// model-space XY plane at z≈0 (WoW axes, Z up) and get buried by sloped terrain (Battle Shout's +/// crescents are the canonical case: 6 batches, each a 4-vert quad, every vertex exactly z=0, each +/// quad skinned 100% to a single bone). Per batch (same batch/geoset resolution `m2batch` uses): +/// FLAT if every vertex has `|z| <= 0.01` in model space; flat batches sub-classify QUAD-1BONE +/// (the crescent shape — [`benilla_formats::RenderSubmesh::ground_quad`], the ground-fx decal +/// lane's own detector) vs OTHER-FLAT (flat but not that shape, staying on the ordinary render +/// path) — which decides how general the renderer mechanism has to be. +pub fn groundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + let (mut scanned, mut hits, mut all_flat, mut mixed) = (0u32, 0u32, 0u32, 0u32); + let (mut quad_total, mut other_total) = (0u32, 0u32); + // The hover census: every ground-quad-SHAPED batch whose uniform plane sits ABOVE z = 0 + // (any height — the shape test is the renderer's own, the ceiling is lifted), the population + // that decides where the decal lane's hover ceiling (`GROUND_HOVER_MAX`) can sit. + let mut hovers: Vec<(f32, String)> = Vec::new(); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); + let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else { + continue; + }; + let total_batches = subs.len(); + let (mut flat_count, mut quad_count, mut other_count) = (0u32, 0u32, 0u32); + let mut blend_modes: Vec = Vec::new(); + let mut model_hovers: Vec = Vec::new(); + for s in &subs { + if s.positions.is_empty() { + continue; + } + if let Some((_, hover)) = s.ground_quad_hover(f32::INFINITY) { + if hover > 0.01 { + hovers.push((hover, name.clone())); + model_hovers.push(hover); + } + } + if !s.positions.iter().all(|v| v[2].abs() <= 0.01) { + continue; + } + flat_count += 1; + let bm = format!("{:?}", s.blend); + if !blend_modes.contains(&bm) { + blend_modes.push(bm); + } + // The RENDERER's own detector, so this report is exactly what the ground-fx + // decal lane will do with each batch — the instrument can't drift from the + // mechanism it measures. + if s.ground_quad().is_some() { + quad_count += 1; + } else { + other_count += 1; + } + } + if flat_count == 0 && model_hovers.is_empty() { + continue; + } + hits += 1; + quad_total += quad_count; + other_total += other_count; + if flat_count as usize == total_batches { + all_flat += 1; + } else { + mixed += 1; + } + blend_modes.sort(); + let hover_note = if model_hovers.is_empty() { + String::new() + } else { + format!( + " hover[{}]", + model_hovers + .iter() + .map(|h| format!("{h:.2}")) + .collect::>() + .join(" ") + ) + }; + println!( + "{total_batches:>3} batches {flat_count:>3} flat ({quad_count:>2} quad-1bone, {other_count:>2} other-flat){hover_note} blend[{}] {name}", + blend_modes.join(" ") + ); + } + eprintln!( + "{scanned} models scanned, {hits} with flat batches ({all_flat} all-flat, {mixed} mixed); flat batches: {quad_total} QUAD-1BONE, {other_total} OTHER-FLAT" + ); + if !hovers.is_empty() { + hovers.sort_by(|a, b| a.0.total_cmp(&b.0)); + eprintln!( + "{} hovering quad-shaped batches (uniform plane z > 0.01), sorted:", + hovers.len() + ); + for (h, name) in &hovers { + eprintln!(" z {h:>7.3} {name}"); + } + } + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs new file mode 100644 index 00000000..0b02062f --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs @@ -0,0 +1,396 @@ +//! Corpus scans over **what lights a model** — the population instruments for the lighting +//! lanes, on both sides of the WMO/M2 boundary. +//! +//! `darkpropscan` asks which placed WMO props the interior lane commits as literal black +//! (decision 0969's census), `m2lightscan` which M2s author dynamic light blocks at all, and +//! `shadeat` reads the terrain MCSH shadow bit that decides a doodad's sun gain. + +use std::collections::BTreeMap; + +use anyhow::{Context, Result}; +use benilla_formats::{Chain, M2Light}; + +use crate::model_key; + +/// Sweep every WMO **root** (under `prefix`, if given) and list the placed MODD props whose +/// INTERIOR lighting lane commits **literal black**. +/// +/// The interior lane's whole base light is the MODD entry's own baked colour field: ambient = +/// `cap96(colour)`, diffuse = `floor112(colour)` (`0x694e90` create → `0x6a77e0`; wow-re +/// `trace-forensics-abbey-interior-d3d` §1.1). The floor leg *raises* a dim colour to max 112 — but +/// it is a hue-preserving scale by `112/max`, so a colour of exactly `#000000` has nothing to raise +/// and both words come out zero. Such a prop is lit by nothing but its owning group's MOLR fixture +/// lights, and a group carrying none (or none within its authored attenuation disk) leaves it a +/// pure black silhouette. +/// +/// The colour field is zero on ~3% of shipped MODDs — the baker leaves it unbaked for props it +/// treats as exterior — so what decides the symptom is the **class of the groups that reference the +/// prop**, and EXTERIOR WINS (decision 0969: the reference's def is per (MODD, placement) and +/// `0x695aa0` makes the exterior bit absorbing). A prop any exterior group's MODR names is therefore +/// sky-lit and never listed here; the `RESCUED` tally counts them, because taking the *first* +/// referrer instead is exactly what drew Booty Bay's entrance arch as a black silhouette. +pub fn darkpropscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let roots = super::wmo_roots(chain, prefix)?; + + let (mut roots_scanned, mut modds_total, mut zero_colour, mut black, mut dim, mut rescued) = + (0u32, 0u32, 0u32, 0u32, 0u32, 0u32); + let mut by_model: BTreeMap = BTreeMap::new(); + for root_path in roots { + let Ok(bytes) = chain.read_file(&root_path) else { + continue; + }; + let Ok(root) = benilla_formats::parse_wmo_root(&bytes) else { + continue; + }; + roots_scanned += 1; + modds_total += root.doodads().len() as u32; + // Nothing to classify without a zero-colour MODD — skip the group reads entirely. + if !root.doodads().iter().any(|d| d.color[..3] == [0, 0, 0]) { + continue; + } + let lights = benilla_formats::parse_wmo_lights(&bytes); + let stem = root_path + .to_ascii_lowercase() + .strip_suffix(".wmo") + .unwrap_or(&root_path) + .to_string(); + // MODD index -> referring groups, and group -> its MOLR light refs. + let mut refs: BTreeMap> = BTreeMap::new(); + let mut light_refs: BTreeMap> = BTreeMap::new(); + for gi in 0..root.group_count() { + let Ok(gbytes) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else { + continue; + }; + for r in benilla_formats::wmo_group_doodad_refs(&gbytes) { + let e = refs.entry(r).or_default(); + if e.last() != Some(&gi) { + e.push(gi); + } + } + light_refs.insert(gi, benilla_formats::wmo_group_light_refs(&gbytes)); + } + + let infos = root.group_infos(); + let mut printed_header = false; + for (i, d) in root.doodads().iter().enumerate() { + if d.color[..3] != [0, 0, 0] { + continue; + } + zero_colour += 1; + let Some(referrers) = refs.get(&(i as u16)) else { + continue; // ORPHAN: no group names it — the exterior default + }; + let owner = referrers[0]; + // EXTERIOR WINS over every interior referrer (decision 0969) — the MODD-colour lane is + // for props referenced by interior groups ONLY. + if !referrers + .iter() + .all(|g| infos.get(*g as usize).is_some_and(|gi| gi.interior)) + { + if infos.get(owner as usize).is_some_and(|g| g.interior) { + rescued += 1; + } + continue; + } + // The owning group's MOLR omni lights, gated by their own attenuation disk measured + // from the prop's origin (the spawn fold uses the loaded M2's bounds reference point; + // the origin is within a model radius of it, so this is the census approximation). + let in_range = light_refs + .get(&owner) + .map(|ls| { + ls.iter() + .filter_map(|&li| lights.get(li as usize)) + .filter(|l| l.is_omni() && l.attenuation_end > l.attenuation_start) + .filter(|l| { + let dv = [ + l.position[0] - d.position[0], + l.position[1] - d.position[1], + l.position[2] - d.position[2], + ]; + dv.iter().map(|c| c * c).sum::().sqrt() < l.attenuation_end + }) + .count() + }) + .unwrap_or(0); + if in_range == 0 { + black += 1; + *by_model.entry(model_key(&d.model)).or_default() += 1; + } else { + dim += 1; + } + if !printed_header { + println!("{root_path}"); + printed_header = true; + } + let ref_cell = referrers + .iter() + .map(|g| format!("g{g}")) + .collect::>() + .join(" "); + println!( + " modd {i:>5} {verdict:<5} pos ({:>8.2}, {:>8.2}, {:>8.2}) molr {in_range} in range refs(INT) {ref_cell} {}", + d.position[0], + d.position[1], + d.position[2], + model_key(&d.model), + verdict = if in_range == 0 { "BLACK" } else { "dim" }, + ); + } + } + + eprintln!( + "{roots_scanned} root(s), {modds_total} MODD(s): {zero_colour} carry colour #000000; \ + of those, interior-ONLY = {} — {black} BLACK (no MOLR light in range), {dim} dim \ + (a fixture light reaches them). RESCUED by exterior-wins: {rescued} (an interior group \ + names them first, an exterior group also names them — sky-lit, decision 0969).", + black + dim, + ); + if !by_model.is_empty() { + eprintln!("BLACK props by model ({} distinct):", by_model.len()); + let mut rows: Vec<_> = by_model.into_iter().collect(); + rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + for (m, n) in rows { + eprintln!(" {n:>4} {m}"); + } + } + Ok(()) +} + +/// Capitalize an `Item\ObjectComponents\` path component for consistent family-key display +/// regardless of how a given asset's listfile entry happened to be cased (`WEAPON`/`weapon`/ +/// `Weapon` all collapse to `Weapon`). +fn title_case(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(first) => { + first.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase() + } + None => String::new(), + } +} + +/// The top-level content-family bucket for an internal M2 path — `m2lightscan`'s summary +/// dimension for "how much content is affected, and of what kind". Derived from the path's first +/// one or two components, case-insensitively. `World\Goober\` (GameObject displays) splits from +/// plain `World\` (ADT-placed doodads / WMO props) because only the latter is on benilla's +/// current M2-light spawn path (`crate::terrain_stream::spawn::fx::spawn_lights_for`); everything +/// not otherwise named folds into `other`. +fn family_of(name: &str) -> String { + let comps: Vec<&str> = name.split('\\').collect(); + let low = |s: &str| s.to_ascii_lowercase(); + match comps.first().map(|s| low(s)).as_deref() { + Some("creature") => "Creature\\".to_string(), + Some("character") => "Character\\".to_string(), + Some("spells") => "Spells\\".to_string(), + Some("item") if comps.get(1).map(|s| low(s)).as_deref() == Some("objectcomponents") => { + match comps.get(2) { + Some(sub) => format!("Item\\ObjectComponents\\{}\\", title_case(sub)), + None => "Item\\ObjectComponents\\".to_string(), + } + } + Some("world") if comps.get(1).map(|s| low(s)).as_deref() == Some("goober") => { + "World\\Goober\\".to_string() + } + Some("world") => "World\\".to_string(), + _ => "other".to_string(), + } +} + +/// How many rows of the closing colour tally print (the rest are counted, never silently dropped). +const TALLY_ROWS: usize = 20; + +/// Cheap warm/cool/neutral hue classification of a `diffuse_color`, used only to eyeball the +/// colour-tally section of `m2lightscan`'s summary — the warm-torch family vs anything unusual. +fn hue_tag(r: f32, g: f32, b: f32) -> &'static str { + if r >= g && r > b * 1.15 { + "warm" + } else if b > r && b >= g { + "cool" + } else { + "neutral" + } +} + +/// Per-family tally for `m2lightscan`'s summary: how many models in this content family carry +/// lights, how many `type==1` point lights they author in total, how many of those are dark +/// (`visibility_off`), and a handful of example paths. +#[derive(Default)] +struct FamilyStats { + models: u32, + point_lights: u32, + dark: u32, + examples: Vec, +} + +/// Sweep every `.m2` (optionally under a path prefix) and report which models author M2 dynamic +/// LIGHT blocks — the population instrument for the mechanism (decision 0016 / wow-re +/// `system/models/scratch/m2-dynamic-lights.md`). Per model (only models with ≥1 light, printed +/// sorted by path): its `type==1` point-light count vs directional (`type==0`, ambient-feed, not +/// a discrete GL light) count, then per POINT light: bone, model-space position, `diffuse_color × +/// diffuse_intensity` (raw colour, intensity, and the product), authored attenuation start/end, +/// and an `OFF` tag when [`M2Light::visibility_off`] — the one shape (a static `0` visibility +/// key) that keeps a light dark (§9.4). The closing summary is the real deliverable: totals, a +/// breakdown by top-level content family ([`family_of`]) — benilla only spawns these lights for +/// ADT-placed doodads and WMO props today, so this answers how much of the entity path +/// (creatures, held items, GameObjects) is actually missing them — and a cheap diffuse +/// colour×intensity tally ([`hue_tag`]). +pub fn m2lightscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + + // Rounded `(r, g, b) × 100` (int-keyed to stay orderable) — a cheap grouping key for the + // authored diffuse×intensity palette across point lights. + type ColorKey = (i32, i32, i32); + + let (mut scanned, mut hits, mut total_point, mut total_dark) = (0u32, 0u32, 0u32, 0u32); + let mut families: BTreeMap = BTreeMap::new(); + // key -> (hit count, one example model). + let mut color_tally: BTreeMap = BTreeMap::new(); + let mut hit_models: Vec<(String, Vec)> = Vec::new(); + + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let lights = benilla_formats::parse_m2_lights(&bytes); + if lights.is_empty() { + continue; + } + hits += 1; + let point_count = lights.iter().filter(|l| l.is_point()).count() as u32; + let dark_count = lights + .iter() + .filter(|l| l.is_point() && l.visibility_off) + .count() as u32; + total_point += point_count; + total_dark += dark_count; + + let fam = families.entry(family_of(&name)).or_default(); + fam.models += 1; + fam.point_lights += point_count; + fam.dark += dark_count; + if fam.examples.len() < 8 { + fam.examples.push(name.clone()); + } + + for l in lights.iter().filter(|l| l.is_point()) { + let key = ( + (l.diffuse_color[0] * l.diffuse_intensity * 100.0).round() as i32, + (l.diffuse_color[1] * l.diffuse_intensity * 100.0).round() as i32, + (l.diffuse_color[2] * l.diffuse_intensity * 100.0).round() as i32, + ); + color_tally + .entry(key) + .or_insert_with(|| (0, name.clone())) + .0 += 1; + } + + hit_models.push((name, lights)); + } + + hit_models.sort_by(|a, b| a.0.cmp(&b.0)); + for (name, lights) in &hit_models { + let point_count = lights.iter().filter(|l| l.is_point()).count(); + let dir_count = lights.len() - point_count; + println!("{name} {point_count} point, {dir_count} directional"); + for (i, l) in lights.iter().enumerate() { + if !l.is_point() { + continue; + } + let prod = [ + l.diffuse_color[0] * l.diffuse_intensity, + l.diffuse_color[1] * l.diffuse_intensity, + l.diffuse_color[2] * l.diffuse_intensity, + ]; + println!( + " L{i} bone {:>4} pos ({:>9.3}, {:>9.3}, {:>9.3}) diffuse ({:.3}, {:.3}, {:.3}) x {:.3} = ({:.3}, {:.3}, {:.3}) atten [{:.2}, {:.2}]{}", + l.bone, + l.position[0], l.position[1], l.position[2], + l.diffuse_color[0], l.diffuse_color[1], l.diffuse_color[2], + l.diffuse_intensity, + prod[0], prod[1], prod[2], + l.attenuation_start, l.attenuation_end, + if l.visibility_off { " OFF" } else { "" }, + ); + } + } + + println!(); + println!( + "=== summary === {scanned} models scanned, {hits} with light blocks, {total_point} point lights, {total_dark} dark (visibility_off) point lights" + ); + + println!(); + println!("=== by content family ==="); + for (fam, stats) in &families { + println!( + "{fam:<32} {:>4} models {:>4} point lights {:>3} dark e.g. {}", + stats.models, + stats.point_lights, + stats.dark, + stats.examples.join(" · ") + ); + } + + println!(); + println!("=== diffuse colour x intensity tally (point lights, rounded to 0.01) ==="); + let mut ranked: Vec<(&ColorKey, &(u32, String))> = color_tally.iter().collect(); + ranked.sort_by_key(|(_, (count, _))| std::cmp::Reverse(*count)); + for (key, (count, example)) in ranked.iter().take(TALLY_ROWS) { + let (r, g, b) = ( + key.0 as f32 / 100.0, + key.1 as f32 / 100.0, + key.2 as f32 / 100.0, + ); + let tag = hue_tag(r, g, b); + println!("{count:>4}x ({r:.2}, {g:.2}, {b:.2}) {tag:<5} e.g. {example}"); + } + // Never let the top-20 read as "that's all of them". + if let Some(rest) = ranked.len().checked_sub(TALLY_ROWS).filter(|n| *n > 0) { + println!(" … and {rest} rarer colours (top {TALLY_ROWS} shown)"); + } + + Ok(()) +} + +/// The terrain MCSH shadow bit at a world position + an ASCII texel neighborhood (`#` shadowed, +/// `.` lit, `?` off-tile/no-chunk). One MCSH texel is `TILE_SIZE/1024` ≈ 0.52 yd; the grid spans +/// ±8 texels so a doodad base sitting one texel from a shadow edge — the 2.5-vs-0.5 intensity +/// cliff — is visible at a glance. +pub fn shadeat(chain: &mut Chain, map: &str, x: f32, y: f32) -> Result<()> { + let tiles = benilla_formats::load_tiles_around(chain, map, x, y, 0) + .with_context(|| format!("loading the tile under ({x}, {y}) on {map}"))?; + let Some((_, tile)) = tiles.first() else { + anyhow::bail!("no tile exists under ({x}, {y}) on {map}"); + }; + let texel = benilla_formats::TILE_SIZE / 1024.0; + let word = |s: Option| match s { + Some(true) => "SHADOWED (doodad sun intensity 0.5)", + Some(false) => "lit (doodad sun intensity 2.5)", + None => "off-tile / no chunk", + }; + println!( + "MCSH at ({x:.2}, {y:.2}): {}", + word(benilla_formats::mcsh_shadowed_at(&tile.chunks, [x, y, 0.0])) + ); + println!( + "neighborhood, texel {texel:.3} yd — rows +X (north) up, cols +Y (west) left; center marked:" + ); + for dx in (-8i32..=8).rev() { + let mut row = String::new(); + for dy in (-8i32..=8).rev() { + let p = [x + dx as f32 * texel, y + dy as f32 * texel, 0.0]; + let mut c = match benilla_formats::mcsh_shadowed_at(&tile.chunks, p) { + Some(true) => '#', + Some(false) => '.', + None => '?', + }; + if dx == 0 && dy == 0 { + c = if c == '#' { 'S' } else { 'O' }; + } + row.push(c); + } + println!("{row}"); + } + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/material.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/material.rs new file mode 100644 index 00000000..b6959ced --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/material.rs @@ -0,0 +1,398 @@ +//! Corpus scans over **how a batch is textured and blended** — the material side of a model's +//! render submeshes. +//! +//! Blend modes (`blendscan`), per-sequence batch visibility through the alpha combine +//! (`alphascan`), sampler address modes and the UVs that need them (`uvwrapscan`, `texmodescan`), +//! and generated-texcoord environment stages (`envmapscan`). + +use std::collections::BTreeMap; + +use anyhow::Result; +use benilla_formats::Chain; + +/// Sweep every `.m2` (under `prefix`, if given) and list the models whose MATERIAL table authors +/// blend mode 5 (Mod) / 6 (Mod2x) — the multiply-blend census (decision 0528). One line per +/// matching model: its per-material `(flags, blend)` pairs and path. The raw header read (materials +/// count/ofs at `0x84`, 4-byte `{u16 flags, u16 blend}` records) matches `benilla-m2`'s parse. +pub fn blendscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + let (mut scanned, mut hits) = (0u32, 0u32); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let at = |o: usize| -> Option { + Some(u32::from_le_bytes(bytes.get(o..o + 4)?.try_into().ok()?)) + }; + let (Some(n), Some(ofs)) = (at(0x84), at(0x88)) else { + continue; + }; + let mats: Vec<(u16, u16)> = (0..n as usize) + .filter_map(|i| { + let o = ofs as usize + i * 4; + let b = bytes.get(o..o + 4)?; + Some(( + u16::from_le_bytes([b[0], b[1]]), + u16::from_le_bytes([b[2], b[3]]), + )) + }) + .collect(); + if mats.iter().any(|&(_, blend)| blend == 5 || blend == 6) { + hits += 1; + println!("{mats:?} {name}"); + } + } + eprintln!("{scanned} models scanned, {hits} with Mod/Mod2x materials"); + Ok(()) +} + +/// Sweep every `.m2` (under `prefix`, if given) and census the models whose batch visibility is +/// **per sequence** — geometry the reference draws in one animation and skips in another, via the +/// verified alpha combine (`A = colourAlpha × weight`, `A ≤ 0` culls; wow-re +/// `m2-alpha-combine-cull.md`). +/// +/// This is the population instrument for the class of bug where a client bakes the material tracks +/// once and draws the result forever: every model listed here has at least one batch whose authored +/// visibility CHANGES between sequences, so a single-sequence bake is guaranteed to be wrong for it +/// in some animation. Per model it reports how many batches are **hidden in the model's first +/// sequence** (what a doodad-shaped bake would show) versus hidden in *some* sequence, so the two +/// failure directions — drawing geometry that should be hidden, and hiding geometry that should +/// draw — are separated. `m2alpha` then explains one model in full. +pub fn alphascan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + let (mut scanned, mut hits) = (0u32, 0u32); + let mut by_dir: BTreeMap = BTreeMap::new(); + let mut rows: Vec<(String, usize, usize, usize)> = Vec::new(); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); + let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else { + continue; + }; + let seq_count = benilla_formats::parse_m2_animations(&bytes).len(); + if seq_count < 2 { + continue; // a one-sequence model can't disagree with itself + } + // A batch is "hidden in slot s" when its combined factor is 0 across that whole band. The + // sampling grid is coarse on purpose — a batch that so much as flickers non-zero is drawn. + let hidden_in = |sub: &benilla_formats::RenderSubmesh, slot: usize| -> bool { + sub.alpha_anim.as_ref().is_some_and(|a| { + (0..=16u16).all(|k| a.sample(Some(slot), f32::from(k) * 0.25, 0.0) <= 0.0) + }) + }; + let (mut first, mut any, mut varies) = (0usize, 0usize, 0usize); + for sub in &subs { + let h0 = hidden_in(sub, 0); + let mut hid_any = h0; + let mut differs = false; + for slot in 1..seq_count { + let h = hidden_in(sub, slot); + hid_any |= h; + differs |= h != h0; + } + if h0 { + first += 1; + } + if hid_any { + any += 1; + } + if differs { + varies += 1; + } + } + if varies == 0 { + continue; + } + hits += 1; + let top = name.split_once('\\').map(|(d, _)| d).unwrap_or(""); + *by_dir.entry(top.to_ascii_lowercase()).or_default() += 1; + rows.push((name, varies, first, any)); + } + // Loudest first: the models where the most geometry changes hands between sequences. + rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + println!( + "model varies hid@seq0 hid@any" + ); + for (name, varies, first, any) in rows.iter().take(60) { + println!("{name:<60} {varies:>6} {first:>8} {any:>7}"); + } + if rows.len() > 60 { + println!("… and {} more", rows.len() - 60); + } + println!("\n{hits} of {scanned} models author per-sequence batch visibility"); + println!("by top-level directory:"); + for (dir, n) in &by_dir { + println!(" {dir:<16} {n:>5}"); + } + Ok(()) +} + +/// Sweep every `.m2` (optionally under a path prefix) and list the batches whose texture is +/// authored **CLAMP** (`M2Texture.flags` bit 0/1 clear) while the batch's own UVs run **outside +/// `0..1`** — the exact population a repeat-sampling renderer draws wrong (decision 0763, B52/B96). +/// +/// The margin outside `0..1` is deliberate authoring: clamped, it samples the texture's transparent +/// border and the card fades out to nothing. Sampled with repeat it wraps into the opposite edge — +/// on a cutout sheet, the opaque middle — so the margin draws as solid geometry with a hard seam +/// where u or v crosses the wrap. That is why a snow-fir grows pale plates with a crease down each +/// bough, and why the artefact never looked like an extra primitive: it is the *same* card, +/// sampling the wrong texels. +/// +/// `over` is how far past the edge the batch reaches, in UV units — the width of the wrongly-drawn +/// margin as a fraction of the sheet. +pub fn uvwrapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + let (mut scanned, mut batches, mut hits, mut models) = (0u32, 0u32, 0u32, 0u32); + let mut cutout_hits = 0u32; + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); + let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else { + continue; + }; + let mut lines = Vec::new(); + for (i, s) in subs.iter().enumerate() { + if s.uvs.is_empty() { + continue; + } + batches += 1; + let ext = |axis: usize| { + s.uvs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), t| { + (lo.min(t[axis]), hi.max(t[axis])) + }) + }; + let (u, v) = (ext(0), ext(1)); + // Only an axis authored CLAMP can be drawn wrong by repeat; a wrapping axis is meant + // to tile. A hair of float slop past the edge is not a margin — require 1/512 of a + // sheet, well under the thinnest authored border and well over rounding. + const SLOP: f32 = 1.0 / 512.0; + let bad_u = !s.wrap_x && (u.0 < -SLOP || u.1 > 1.0 + SLOP); + let bad_v = !s.wrap_y && (v.0 < -SLOP || v.1 > 1.0 + SLOP); + if !bad_u && !bad_v { + continue; + } + hits += 1; + let cutout = matches!( + s.blend, + benilla_formats::ModelBlend::AlphaTest | benilla_formats::ModelBlend::Blend + ); + if cutout { + cutout_hits += 1; + } + let over = [ + (-u.0).max(0.0), + (u.1 - 1.0).max(0.0), + (-v.0).max(0.0), + (v.1 - 1.0).max(0.0), + ] + .into_iter() + .fold(0.0f32, f32::max); + lines.push(format!( + " batch {i:>3}: {:?} {} verts u[{:+.3}..{:+.3}] v[{:+.3}..{:+.3}] \ + over {over:.3} {}{} tex {}", + s.blend, + s.positions.len(), + u.0, + u.1, + v.0, + v.1, + if bad_u { "U" } else { "-" }, + if bad_v { "V" } else { "-" }, + s.texture.as_deref().unwrap_or("NONE"), + )); + } + if !lines.is_empty() { + models += 1; + println!("{name}"); + for l in lines { + println!("{l}"); + } + } + } + eprintln!( + "{scanned} models scanned, {batches} textured batch(es): {hits} CLAMP-AUTHORED BATCHES \ + SAMPLING OUTSIDE 0..1 across {models} model(s) — {cutout_hits} of them cutout/blend, \ + where wrapping changes the silhouette rather than just the colour" + ); + Ok(()) +} + +/// Sweep every `.m2` and report, per texture path, which sampler ADDRESS MODES the corpus asks of +/// it — and how many paths are asked for **more than one** (decision 0763). +/// +/// The design question behind it: the address mode lives on the GPU sampler, which in our asset +/// layer is a property of the loaded `Image`, which is keyed by path. If a `.blp` is only ever +/// asked for one mode, path-keying stays correct and the mode can simply ride the load. Every path +/// asked for two needs two uploads, or one of its users renders wrong. +pub fn texmodescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + // texture path -> set of (wrap_x, wrap_y) asked for, as a 4-bit mask + let mut modes: std::collections::BTreeMap = std::collections::BTreeMap::new(); + let mut scanned = 0u32; + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); + let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else { + continue; + }; + for s in &subs { + let Some(tex) = s.texture.as_deref() else { + continue; + }; + let bit = 1u8 << ((s.wrap_x as u8) | ((s.wrap_y as u8) << 1)); + *modes.entry(tex.to_ascii_lowercase()).or_default() |= bit; + } + } + let mut conflicted = 0u32; + for (path, mask) in &modes { + if mask.count_ones() > 1 { + conflicted += 1; + let want = |b: u8, s: &'static str| if mask & (1 << b) != 0 { s } else { "" }; + println!( + "CONFLICT {path} asked as: {}{}{}{}", + want(0, "[clamp,clamp] "), + want(1, "[repeat,clamp] "), + want(2, "[clamp,repeat] "), + want(3, "[repeat,repeat] "), + ); + } + } + eprintln!( + "{scanned} models scanned, {} distinct texture path(s): {conflicted} asked for MORE THAN \ + ONE address mode (each needs its own upload, or one of its users renders wrong)", + modes.len() + ); + Ok(()) +} + +/// Sweep every `.m2` (optionally under a path prefix) and census the batches whose texture +/// coordinates are **GENERATED, not authored** — the sphere-map environment stages +/// (`texture_unit_lookup[texCoordSet] > 2`, the reference's gate at `0x70b8bd`). +/// +/// The population instrument for a silent class: such a batch carries no usable UVs *by design* — +/// the artist collapses the whole mesh onto one point because the runtime is meant to supply the +/// coordinates — so a renderer that reads the vertex UV draws the entire surface in **one texel** +/// of a reflection sheet. Nothing about that failure is loud: no missing geometry, no error, just a +/// flat wash of whatever colour happens to sit at that corner (`GnomeSubwayGlass.m2` → the Deeprun +/// Tram tube's yellow, `AKGNOMEREFLECT.BLP` texel 0,0 = 225,221,142, doubled by its Mod2x blend). +/// +/// **DEGENERATE** marks the batches where the authored UVs collapse to a single point — the ones +/// that render as a flat colour field. The rest carry leftover UVs that merely go unused, so they +/// misdraw as a static smear of the sheet instead: wrong, but not obviously so. Both are fixed by +/// the same mechanism; the split says which reports a renderer's env support explains. +pub fn envmapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + let (mut scanned, mut batches, mut hits, mut models, mut degenerate) = + (0u32, 0u32, 0u32, 0u32, 0u32); + // Which blend modes and which sheets the mechanism actually serves — a Mod2x env layer tints + // what is behind it (glass), an Add one lays a highlight over the surface (the metal sheen). + let mut by_blend: BTreeMap = BTreeMap::new(); + let mut by_sheet: BTreeMap = BTreeMap::new(); + // **The fallback census.** `stage_is_env_mapped` reads an OUT-OF-RANGE `texture_unit_lookup` + // index as env — the reference's own unguarded read, and the only safe direction. But that + // branch is the one way the gate can *invent* env-mapping on a model whose art never asked for + // it, so it is counted separately: a hit is trustworthy exactly when it came from a real + // `>= 3` entry. `empty_table` is the degenerate shape of the same thing (no table at all ⇒ + // every batch falls through), broken out because it would tar a whole model rather than a + // stage. + let (mut from_oob, mut empty_table) = (0u32, 0u32); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + if let Ok(fmt) = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes[..])) { + let model = fmt.model(); + if model.texture_unit_lookup.is_empty() { + empty_table += 1; + } + if let Ok(skin) = model.parse_embedded_skin(&bytes, 0) { + for b in skin.batches() { + let idx = b.texture_coord_combo_index as usize; + if model.texture_unit_lookup.get(idx).is_none() { + from_oob += 1; + } + } + } + } + let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); + let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else { + continue; + }; + let mut lines = Vec::new(); + for (i, s) in subs.iter().enumerate() { + batches += 1; + if !s.env_map { + continue; + } + hits += 1; + *by_blend.entry(format!("{:?}", s.blend)).or_default() += 1; + let sheet = s.texture.as_deref().unwrap_or("NONE").to_string(); + *by_sheet.entry(sheet.clone()).or_default() += 1; + // Does the authored UV set collapse to a point? Then the vertex data cannot even + // approximate the sheet and the batch renders as one flat colour. + let span = |axis: usize| { + s.uvs.iter().fold((f32::MAX, f32::MIN), |(lo, hi), t| { + (lo.min(t[axis]), hi.max(t[axis])) + }) + }; + let (u, v) = (span(0), span(1)); + let flat = s.uvs.is_empty() || (u.1 - u.0 <= 1e-6 && v.1 - v.0 <= 1e-6); + if flat { + degenerate += 1; + } + lines.push(format!( + " batch {i:>3}: {:?}{}{} {} verts authored uv u[{:+.3}..{:+.3}] \ + v[{:+.3}..{:+.3}] {} tex {sheet}", + s.blend, + if s.additive { " additive" } else { "" }, + if s.emissive { " unlit" } else { "" }, + s.positions.len(), + u.0, + u.1, + v.0, + v.1, + if flat { "DEGENERATE" } else { "unused" }, + )); + } + if !lines.is_empty() { + models += 1; + println!("{name}"); + for l in lines { + println!("{l}"); + } + } + } + eprintln!("blend modes: {by_blend:?}"); + let mut sheets: Vec<_> = by_sheet.into_iter().collect(); + sheets.sort_by_key(|(_, n)| std::cmp::Reverse(*n)); + eprintln!("top reflection sheets:"); + for (sheet, n) in sheets.iter().take(10) { + eprintln!(" {n:>5} batch(es) {sheet}"); + } + eprintln!( + "gate provenance: {from_eal} of {hits} hit(s) came from a REAL `>= 3` table entry, \ + {from_oob} from the out-of-range fallback ({empty_table} model(s) carry no \ + texture_unit_lookup at all)", + from_eal = hits.saturating_sub(from_oob), + ); + eprintln!( + "{scanned} models scanned, {batches} batch(es): {hits} ENV-MAPPED (generated texcoords) \ + across {models} model(s) — {degenerate} of them DEGENERATE (authored UVs collapse to a \ + point, so a renderer reading the vertex UV draws one flat texel), {} carrying leftover \ + UVs that merely go unused. {} distinct reflection sheet(s).", + hits - degenerate, + sheets.len(), + ); + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs new file mode 100644 index 00000000..0543e32b --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs @@ -0,0 +1,89 @@ +//! Corpus-scan reports: the **population instruments** that sweep many models or placements to +//! answer "how big is this class, and where does it live", rather than dumping one asset (that is +//! [`crate::m2dump`]'s job). Every one of them is reached as `scan::` from the dispatch in +//! [`crate::main`]; the submodules group them by the question they answer, not by the file format +//! they read: +//! +//! - [`world`] — placed content: a WMO root's own tables, an ADT block's placements. +//! - [`lighting`] — what lights a model: the WMO prop lanes, M2 light blocks, terrain shadow. +//! - [`geometry`] — what geometry a model draws: billboards, geosets, flat ground quads. +//! - [`material`] — how a batch is textured and blended: blend modes, UV wrap, env stages. +//! - [`particles`] — particle and ribbon emitters, and the features the corpus authors. +//! - [`skeleton`] — the bone tree and the attachment table that addresses it. +//! - [`sequence`] — which sequence an arm plays, and what breaks when it is the wrong one. +//! +//! Split out of a single 3.9k-line `scan.rs` once it had grown ~30 independent sweeps: the file +//! was one *kind* of thing, but not one concern, and nothing in it was shared across families +//! except the corpus listing below. + +use anyhow::{Context, Result}; +use benilla_formats::Chain; + +mod geometry; +mod lighting; +mod material; +mod particles; +mod sequence; +mod skeleton; +mod world; + +pub use geometry::{bbfacescan, bbscan, geosetscan, groundscan}; +pub use lighting::{darkpropscan, m2lightscan, shadeat}; +pub use material::{alphascan, blendscan, envmapscan, texmodescan, uvwrapscan}; +pub use particles::{ + cellscan, fxordercensus, partcensus, partscan, partslotscan, ribbonscan, shardcensus, +}; +pub use sequence::{fxlifescan, goanimscan, idleslotscan, seqclockscan}; +pub use skeleton::{attachscan, bonescan}; +pub use world::{doodadscan, placescan, skyboxscan, wmodoodads}; + +/// Every `.m2` in the chain, in listfile order, narrowed to a path `prefix` when one is given. +/// +/// The opening line of nearly every sweep here, and — before the split — twenty-two copy-pasted +/// copies of it. Names come back in their listfile casing (that is what a report prints and what +/// [`Chain::read_file`] is handed); the `.m2` test and the prefix match are both done on a +/// lowercased copy, and the prefix is normalized to the chain's own `\` separators, so a caller +/// may pass either slash in either case. +fn m2_names(chain: &mut Chain, prefix: Option<&str>) -> Result> { + let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); + Ok(chain + .list() + .context("listing chain contents")? + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_ascii_lowercase(); + l.ends_with(".m2") && pfx.as_deref().is_none_or(|p| l.starts_with(p)) + }) + .collect()) +} + +/// Every WMO **root** in the chain, narrowed to a path `prefix` when one is given — same casing +/// and matching rules as [`m2_names`]. +/// +/// A WMO ships as one root plus one file per group, named `_NNN.wmo`. Only the root carries +/// the tables a sweep wants (MOHD/MODS/MODD/MOGI/MOLT/MOSB); a group file handed to +/// [`benilla_formats::parse_wmo_root`] is just a parse failure to skip. Underscore then exactly +/// three digits is the whole test — and it is here, once, because the two sweeps that used to +/// spell it inline had drifted into spelling it *differently* (one guarded a stem shorter than +/// four characters, the other did not, so a hypothetical `123.wmo` was a root to one and a group +/// to the other). +fn wmo_roots(chain: &mut Chain, prefix: Option<&str>) -> Result> { + let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\")); + Ok(chain + .list() + .context("listing chain contents")? + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_ascii_lowercase(); + let Some(stem) = l.strip_suffix(".wmo") else { + return false; + }; + let group = stem.len() >= 4 + && stem.as_bytes()[stem.len() - 4] == b'_' + && stem[stem.len() - 3..].bytes().all(|b| b.is_ascii_digit()); + !group && pfx.as_deref().is_none_or(|p| l.starts_with(p)) + }) + .collect()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/particles.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/particles.rs new file mode 100644 index 00000000..c3b046f6 --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/particles.rs @@ -0,0 +1,836 @@ +//! Corpus scans over **particle and ribbon emitters** — which of the emitter system's features +//! the shipped data actually authors, and which shapes a consumer can get wrong. +//! +//! The feature census (`partcensus`), the flipbook ramps (`cellscan`), the per-slot emission +//! windows (`partslotscan`), the 3-D geometry-model shards (`shardcensus`), and the draw-order +//! population effects share with their owner's own transparent batches (`fxordercensus`). + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + +use anyhow::Result; +use benilla_formats::Chain; + +use crate::model_key; + +/// Sweep every `.m2` in the chain and list the models carrying RIBBON emitters. +pub fn ribbonscan(chain: &mut Chain) -> Result<()> { + let names = super::m2_names(chain, None)?; + let (mut scanned, mut hits) = (0u32, 0u32); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let n = benilla_formats::m2_ribbon_emitter_count(&bytes); + if n > 0 { + hits += 1; + println!("{n:>2} {name}"); + } + } + eprintln!("{scanned} models scanned, {hits} with ribbons"); + Ok(()) +} + +/// Sweep every `.m2` (under `prefix`, if given) and list the models whose particle emitters +/// carry any bit of `mask` in their file flags — see the `Partscan` command doc. One line per +/// matching emitter: its index, full flag word, shape/type, and the model path. +pub fn partscan(chain: &mut Chain, mask: u32, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + let (mut scanned, mut hits, mut emitters) = (0u32, 0u32, 0u32); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let Ok(defs) = benilla_formats::parse_m2_particle_emitters(&bytes) else { + continue; + }; + let mut hit = false; + for (i, d) in defs.iter().enumerate() { + if d.flags & mask != 0 { + hit = true; + emitters += 1; + println!( + "e{i} flags {:#010x} {:?} {} {name}", + d.flags, + d.shape, + match d.head_tail { + 0 => "head", + 1 => "tail", + _ => "head+tail", + }, + ); + } + } + if hit { + hits += 1; + } + } + eprintln!( + "{scanned} models scanned, {hits} with mask {mask:#x} emitters ({emitters} emitters)" + ); + Ok(()) +} + +/// The census's raw-extras view of one on-disk emitter record: the fields the shipped +/// [`benilla_formats::ParticleEmitterDef`] deliberately does not carry (yet). Read straight off +/// the record bytes (stride/header per the parser's module doc): the two model-filename M2Arrays +/// at `+0x18` (geometry model — 3-D "model particles") and `+0x20` (recursion model — per-particle +/// child emitters), and the emission-rate track's interpolation word (`+0xdc`, 0 = step). +struct RecordExtras { + geometry_model: Option, + recursion_model: Option, + rate_interp: u16, + rate_keys: u32, +} + +/// Read the raw extras for every emitter record in an M2 (empty if not an MD20 or no emitters). +fn record_extras(bytes: &[u8]) -> Vec { + const STRIDE: usize = 0x1f8; + let u16_at = |o: usize| u16::from_le_bytes([bytes[o], bytes[o + 1]]); + let u32_at = |o: usize| u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()); + let str_at = |count: usize, ofs: usize| -> Option { + if count == 0 || ofs == 0 || ofs + count > bytes.len() { + return None; + } + let s = String::from_utf8_lossy(&bytes[ofs..ofs + count]) + .trim_end_matches('\0') + .to_string(); + (!s.is_empty()).then_some(s) + }; + if bytes.len() < 0x144 || &bytes[..4] != b"MD20" { + return Vec::new(); + } + let count = u32_at(0x13c) as usize; + let base = u32_at(0x140) as usize; + if count == 0 || count > 256 || base + count * STRIDE > bytes.len() { + return Vec::new(); + } + (0..count) + .map(|i| { + let e = base + i * STRIDE; + RecordExtras { + geometry_model: str_at(u32_at(e + 0x18) as usize, u32_at(e + 0x1c) as usize), + recursion_model: str_at(u32_at(e + 0x20) as usize, u32_at(e + 0x24) as usize), + rate_interp: u16_at(e + 0xdc), + rate_keys: u32_at(e + 0xdc + 0x14), + } + }) + .collect() +} + +/// model key → the spells whose visual chain plays that model (any kit stage, or the missile). +fn spell_attribution(chain: &mut Chain) -> HashMap> { + let (Ok(spells), Ok(visuals)) = ( + benilla_formats::load_spell_catalog(chain), + benilla_formats::load_spell_visual_catalog(chain), + ) else { + eprintln!("(spell/visual DBCs unavailable — census runs without spell attribution)"); + return HashMap::new(); + }; + let mut map: HashMap> = HashMap::new(); + for (id, sp) in spells.iter() { + let Some(st) = visuals.stages(sp.visual) else { + continue; + }; + let mut push = |effect: u32| { + if effect != 0 { + if let Some(p) = visuals.effect_path(effect) { + map.entry(crate::model_key(p)) + .or_default() + .push((id, sp.name.clone())); + } + } + }; + for kit_id in [st.precast, st.cast, st.impact, st.state, st.channel] { + if kit_id == 0 { + continue; + } + if let Some(kit) = visuals.kit(kit_id) { + for (_, effect) in kit.effects() { + push(effect); + } + } + } + push(st.missile_model); + } + map +} + +/// One census dimension's tally. `model_count` counts every distinct model exactly; +/// `models` keeps the first 64 (sorted) for the example listings. +#[derive(Default)] +struct Tally { + emitters: u32, + model_count: u32, + last_model: String, + models: std::collections::BTreeSet, +} + +impl Tally { + /// The walk visits each model's emitters consecutively, so "new model" is a change of name. + fn hit(&mut self, model: &str) { + self.emitters += 1; + if self.last_model != model { + self.model_count += 1; + self.last_model = model.to_string(); + } + if self.models.len() < 64 { + self.models.insert(model.to_string()); + } + } +} + +/// Sweep every `.m2` (under `prefix`, if given) and census which particle-emitter FEATURES the +/// corpus actually authors — see the `Partcensus` command doc. +pub fn partcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let attribution = spell_attribution(chain); + let names = super::m2_names(chain, prefix)?; + + let mut tallies: std::collections::BTreeMap<&'static str, Tally> = + std::collections::BTreeMap::new(); + let (mut scanned, mut with_emitters, mut total_emitters) = (0u32, 0u32, 0u32); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let Ok(defs) = benilla_formats::parse_m2_particle_emitters(&bytes) else { + continue; + }; + if defs.is_empty() { + continue; + } + with_emitters += 1; + let extras = record_extras(&bytes); + let key = crate::model_key(&name); + for (i, d) in defs.iter().enumerate() { + total_emitters += 1; + let mut hit = |k: &'static str| tallies.entry(k).or_default().hit(&key); + match d.shape { + benilla_formats::ParticleShape::Plane => hit("shape:plane"), + benilla_formats::ParticleShape::Sphere => hit("shape:sphere"), + benilla_formats::ParticleShape::Spline => hit("shape:SPLINE"), + } + // ANISOTROPIC plane rectangles — the population on which the areaLength↔areaWidth axis + // pairing is observable at all (a SQUARE area renders identically either way). 0563 and + // 0566 both pre-named "a 90°-wrong ANISOTROPIC effect" as this lane's suspect, but no + // instrument could LIST that population, so the swapped pairing outlived both audits + // until Gressil's 0.1 × 1.1 blade smoke drew its curtain across the blade. Bucketed by + // aspect so a thin curtain (load-bearing) separates from a near-square (invisible). + let now = d.params.sample(None, 0.0, 0.0); + if d.shape == benilla_formats::ParticleShape::Plane { + let lo = now.area_length.abs().min(now.area_width.abs()); + let hi = now.area_length.abs().max(now.area_width.abs()); + if hi > 1e-4 { + let aspect = if lo > 1e-4 { hi / lo } else { f32::INFINITY }; + if aspect >= 4.0 { + hit("plane-area:ANISOTROPIC >=4:1"); + } else if aspect >= 1.5 { + hit("plane-area:anisotropic >=1.5:1"); + } + } + } + // ANIMATED parameter channels (decision 0844): the population the value[0] flatten + // silently mis-rendered — Frost Nova's emission radius riding its ring out, Arcane + // Explosion's riding its dome. Tallied per channel so the census names WHICH knob + // actually moves in the corpus. + const PARAM_KEYS: [&str; 9] = [ + "param-anim:speed", + "param-anim:speedVar", + "param-anim:latitude", + "param-anim:longitude", + "param-anim:gravity", + "param-anim:lifespan", + "param-anim:areaLength", + "param-anim:areaWidth", + "param-anim:zSource", + ]; + for (c, (_, slots)) in d.params.channel_views().iter().enumerate() { + if slots + .iter() + .any(|k| k.is_some_and(|k| k.len() > 1 && k.iter().any(|&(_, v)| v != k[0].1))) + { + hit(PARAM_KEYS[c]); + } + } + match d.head_tail { + 0 => hit("type:head"), + 1 => hit("type:tail"), + _ => hit("type:head+tail"), + } + match d.blend { + benilla_formats::ParticleBlend::Add => hit("blend:add"), + benilla_formats::ParticleBlend::Alpha => hit("blend:alpha"), + benilla_formats::ParticleBlend::Opaque => hit("blend:opaque"), + } + // The raw blend byte disambiguates what the parsed enum folds: 5 = Mod, 6 = Mod2x. + { + const STRIDE: usize = 0x1f8; + let base = u32::from_le_bytes(bytes[0x140..0x144].try_into().unwrap()) as usize; + match bytes[base + i * STRIDE + 0x28] { + 5 => hit("blend:MOD(5)"), + 6 => hit("blend:MOD2X(6)"), + _ => {} + } + } + if d.flags & 0x1 == 0 { + hit("flag:LIT(0x1 clear)"); + } + for (bit, label) in [ + (0x8u32, "flag:0x8 texenv"), + (0x10, "flag:0x10 model-space"), + (0x20, "flag:0x20 scale-by-instance"), + (0x40, "flag:0x40 MOTION-VEL-INHERIT"), + (0x80, "flag:0x80 KILL-OUTBOUND"), + (0x100, "flag:0x100 sphere-up"), + (0x200, "flag:0x200"), + (0x400, "flag:0x400 tail-age-clamp"), + (0x800, "flag:0x800 SPAWN-PATH-SPREAD"), + (0x1000, "flag:0x1000 xy-quad"), + (0x2000, "flag:0x2000 GROUND-SNAP"), + (0x4000, "flag:0x4000 FOLLOW-DELTA"), + (0x8000, "flag:0x8000 burst"), + ] { + if d.flags & bit != 0 { + hit(label); + } + } + if d.flags >> 16 != 0 { + hit("flag:HIGH-BITS(>0xffff)"); + } + if d.spin > 0.0 { + hit("spin:positive"); + } + if d.spin < 0.0 { + hit("spin:negative"); + } + if d.twinkle_percent < 1.0 { + hit("twinkle:percent<1"); + } + if u32::from(d.tile_cols) * u32::from(d.tile_rows) > 1 { + hit("tiles:atlas"); + if !d.tile_cols.is_power_of_two() { + hit("tiles:NON-POW2-COLS"); + } + } + if now.z_source != 0.0 { + hit("kernel:zSource"); + } + if now.gravity < 0.0 { + hit("kernel:negative-gravity"); + } + if d.shape == benilla_formats::ParticleShape::Sphere + && now.vertical_range > 3.0 + && now.horizontal_range == 0.0 + { + hit("kernel:edge-on-ring(lat±π,lon0)"); + } + if let Some(x) = extras.get(i) { + if x.geometry_model.is_some() { + hit("MODEL-PARTICLES(geometry)"); + } + if x.recursion_model.is_some() { + hit("CHILD-EMITTERS(recursion)"); + } + if x.rate_keys > 1 && x.rate_interp != 0 { + hit("rate:LERP-RAMP(interp!=0)"); + // A BURST emitter with a lerp rate track would arm a near-zero count at its + // rising edge — if the corpus authored one, the burst edge law needs a re-look. + if d.burst() { + hit("rate:BURST+LERP(suspect)"); + } + } + } + } + } + + println!( + "== particle feature census prefix={} ({scanned} models scanned, {with_emitters} with emitters, {total_emitters} emitters)", + prefix.unwrap_or(""), + ); + for (k, t) in &tallies { + let ex: Vec<&str> = t.models.iter().take(3).map(|s| s.as_str()).collect(); + println!( + "{:>6} emitters {:>4} models {k} e.g. {}", + t.emitters, + t.model_count, + ex.join(" · ") + ); + } + + // The full model list — with spell attribution — for the dimensions that decide mechanism + // scope (the UPPERCASE keys: unimplemented or folded legs, plus the odd corners). + let detail: &[&str] = &[ + "MODEL-PARTICLES(geometry)", + "CHILD-EMITTERS(recursion)", + "shape:SPLINE", + "blend:MOD(5)", + "blend:MOD2X(6)", + "flag:LIT(0x1 clear)", + "flag:0x40 MOTION-VEL-INHERIT", + "flag:0x80 KILL-OUTBOUND", + "flag:0x800 SPAWN-PATH-SPREAD", + "flag:0x2000 GROUND-SNAP", + "flag:0x4000 FOLLOW-DELTA", + "flag:HIGH-BITS(>0xffff)", + "tiles:NON-POW2-COLS", + "rate:LERP-RAMP(interp!=0)", + ]; + for k in detail { + let Some(t) = tallies.get(k) else { continue }; + println!(); + println!( + "=== {k} ({} emitters, {} models)", + t.emitters, t.model_count + ); + for m in t.models.iter().take(16) { + let spells = attribution.get(m).map_or(String::new(), |v| { + let mut seen = HashSet::new(); + let names: Vec = v + .iter() + .filter(|(id, _)| seen.insert(*id)) + .take(3) + .map(|(id, n)| format!("{id} {n}")) + .collect(); + if names.is_empty() { + String::new() + } else { + format!( + " [{}{}]", + names.join(", "), + if v.len() > 3 { ", …" } else { "" } + ) + } + }); + println!(" {m}{spells}"); + } + if t.model_count as usize > 16 { + println!(" … {} more", t.model_count as usize - 16); + } + } + Ok(()) +} + +/// Sweep every `.m2` and census its particle emitters' over-life **flipbook** fields — the +/// population instrument behind decision 0685 (the reverse-playing cell ramp). +/// +/// One line per emitter that is interesting on any axis, then the totals. The axes are exactly the +/// ways a flipbook reader can be wrong, each of which shipped data does exercise: +/// +/// - `INVERTED` — a `begin > end` pair. Legal, and it means *play the sheet backwards*; a reader +/// that clamps into `[begin, end]` mangles it (and in Rust panics outright). +/// - `TAIL-RAMP` — the tail streak's own ramp differs from the head's, on an emitter that draws a +/// tail. The two are independently authored (file +0x168.. vs +0x174..); handing the head's cell +/// to the streak animates it through a sheet the author pinned to one cell. +/// - `PAST-ATLAS` — a cell index beyond `rows·cols`. The reference masks the COLUMN and leaves the +/// ROW unbounded, so the index wraps to row 0 rather than holding the last cell. +/// - `REPEAT` — a per-segment repeat count ≠ 1, i.e. the sheet cycles more than once per segment. +/// - `NON-POW2` / `MID` — the two shapes the reference itself degrades on (a 1×1 fallback, and a +/// `mid` of 0/1 that walks its own sampler into a NaN). Both are empty in 1.12.1 and are here so +/// that stays checkable. +pub fn cellscan(chain: &mut Chain) -> Result<()> { + let names = super::m2_names(chain, None)?; + + let (mut models, mut emitters) = (0u32, 0u32); + let (mut inverted, mut tail_ramp, mut repeat_ne1) = (0u32, 0u32, 0u32); + let (mut past_atlas, mut past_atlas_real, mut bad_tiles, mut bad_mid) = + (0u32, 0u32, 0u32, 0u32); + + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + models += 1; + let Ok(defs) = benilla_formats::parse_m2_particle_emitters(&bytes) else { + continue; + }; + for (i, e) in defs.iter().enumerate() { + emitters += 1; + let ol = &e.over_life; + let at = format!("{name} [{i}] {}x{}", e.tile_rows, e.tile_cols); + let ramps = [ + ol.head_cells[0], + ol.head_cells[1], + ol.tail_cells[0], + ol.tail_cells[1], + ]; + let pair = |r: &benilla_formats::CellRamp| (r.begin, r.end); + + if ramps.iter().any(|r| r.begin > r.end) { + inverted += 1; + println!( + "INVERTED {at} head {:?}/{:?}", + pair(&ol.head_cells[0]), + pair(&ol.head_cells[1]) + ); + } + // Only a tail-drawing emitter (particleType 1/2) can show a tail-ramp difference. + if e.head_tail >= 1 && ol.tail_cells != ol.head_cells { + tail_ramp += 1; + println!( + "TAIL-RAMP {at} head {:?}/{:?} tail {:?}/{:?}", + pair(&ol.head_cells[0]), + pair(&ol.head_cells[1]), + pair(&ol.tail_cells[0]), + pair(&ol.tail_cells[1]) + ); + } + if ol.repeat.iter().any(|&r| r != 1.0) { + repeat_ne1 += 1; + println!("REPEAT {at} {:?}", ol.repeat); + } + let atlas = e.tile_rows * e.tile_cols; + if ramps.iter().any(|r| r.begin >= atlas || r.end >= atlas) { + past_atlas += 1; + // On a 1×1 sheet every index resolves to the same texture, so only a real atlas + // can show the wrap. + if atlas > 1 { + past_atlas_real += 1; + println!( + "PAST-ATLAS {at} head {:?}/{:?}", + pair(&ol.head_cells[0]), + pair(&ol.head_cells[1]) + ); + } + } + if !e.tile_rows.is_power_of_two() || !e.tile_cols.is_power_of_two() { + bad_tiles += 1; + println!("NON-POW2 {at}"); + } + if !(ol.mid > 0.0 && ol.mid < 1.0) { + bad_mid += 1; + println!("MID {at} mid {}", ol.mid); + } + } + } + + eprintln!( + "{models} models / {emitters} emitters scanned\n \ + INVERTED {inverted}\n \ + TAIL-RAMP {tail_ramp}\n \ + REPEAT {repeat_ne1}\n \ + PAST-ATLAS {past_atlas} ({past_atlas_real} on a real >1x1 atlas)\n \ + NON-POW2 {bad_tiles}\n \ + MID 0 or 1 {bad_mid}" + ); + Ok(()) +} + +/// Sweep every `.m2` (optionally under a path prefix) and list the emitters whose **file slot 0 is +/// dead while another slot is alive** — the shape that makes a pinned-slot-0 consumer silently +/// render nothing (decision 0760, found on `BlastedLandsLightningbolt01.m2`, B63). +/// +/// The reference samples the **playing** sequence's rate window every frame (wow-re +/// `part-emission-rate-animated.md` §2); a consumer that pins slot 0 instead is only correct while +/// slot 0 carries the emitter's whole story. When an author keys the burst in a *later* variation — +/// a lightning strike that fires on 5 % of arms, an ambient prop with a rare flourish — slot 0 is a +/// flat zero and the pinned consumer emits nothing, for ever, on every placement. That is invisible +/// from the outside: the emitter is built, pooled, and ticking; it just never births a particle. +/// +/// `peak0` is slot 0's own peak rate, `peakN` the best any other slot reaches. A listed emitter has +/// `peak0 <= 0 < peakN`. `slots` counts FILE sequence slots (the axis `EmitTiming` bakes on). +pub fn partslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + let (mut scanned, mut with_emitters, mut emitters, mut dead0) = (0u32, 0u32, 0u32, 0u32); + let mut models = 0u32; + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let Ok(defs) = benilla_formats::parse_m2_particle_emitters(&bytes) else { + continue; + }; + if defs.is_empty() { + continue; + } + with_emitters += 1; + let mut lines = Vec::new(); + for (i, d) in defs.iter().enumerate() { + emitters += 1; + let views = d.timing.slot_views(); + if views.len() < 2 { + continue; // one slot ⇒ nothing for a slot pick to get wrong + } + // Peak of a slot's baked rate keys; an unkeyed slot emits nothing (`None` ⇒ rate 0). + let peak = |s: usize| -> f32 { + views + .get(s) + .and_then(|v| v.1) + .map(|keys| keys.iter().map(|&(_, v)| v).fold(0.0, f32::max)) + .unwrap_or(0.0) + }; + let peak0 = peak(0); + let (mut best, mut best_slot) = (0.0f32, 0usize); + for s in 1..views.len() { + if peak(s) > best { + best = peak(s); + best_slot = s; + } + } + if peak0 <= 0.0 && best > 0.0 { + dead0 += 1; + lines.push(format!( + " emitter {i:>2}: slots {:>2} peak0 {peak0:>7.1} peakN {best:>7.1} \ + @ slot {best_slot} tex {}", + views.len(), + d.texture.as_deref().unwrap_or("NONE"), + )); + } + } + if !lines.is_empty() { + models += 1; + println!("{name}"); + for l in lines { + println!("{l}"); + } + } + } + eprintln!( + "{scanned} models scanned, {with_emitters} with emitters, {emitters} emitter(s): \ + {dead0} DEAD-IN-SLOT-0 across {models} model(s) — these emit nothing at all under a \ + pinned-slot-0 consumer" + ); + Ok(()) +} + +/// Sweep every `.m2` (optionally under a path prefix) and census the corpus's **3-D model +/// particles**: the emitters whose record carries a geometry-model reference (`+0x18` — a shard +/// instance renders that GEOMETRY model's own submeshes, not a billboard quad). Per OWNER model: +/// the owner-last draw-order rung its shards are stamped with at runtime +/// (`owner_last_rung(m2_owner_reach(owner submeshes))`); per referenced GEOMETRY model: the +/// material family tuples its render submeshes author — (blend, two_sided, additive, +/// no_depth_write, no_depth_test), the exact key the shard's material is built from. +/// +/// This is the **ground truth for the pipeline-warm menagerie's shard rows**: warming a pipeline +/// per (rung × family) cell is only worth its table if the corpus's cells are enumerable, and this +/// census is what enumerates them — the rung histogram sizes the rung axis, the family-tuple set +/// (split by transparent-pass membership, the renderer's own test) sizes the material axis, and +/// the unresolved-path count bounds what the table can never cover. +pub fn shardcensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + + // A submesh's material family: the tuple the shard material is keyed by, rendered compactly + // (`Blend+2s+add`), plus its transparent-pass membership — the renderer's own test, where + // `additive` forces the pass whatever the authored blend says (`model_material`). + let fam = |s: &benilla_formats::RenderSubmesh| { + let transparent = s.additive + || matches!( + s.blend, + benilla_formats::ModelBlend::Blend + | benilla_formats::ModelBlend::Mod + | benilla_formats::ModelBlend::Mod2x + ); + let label = format!( + "{:?}{}{}{}{}", + s.blend, + if s.two_sided { "+2s" } else { "" }, + if s.additive { "+add" } else { "" }, + if s.no_depth_write { "+ndw" } else { "" }, + if s.no_depth_test { "+ndt" } else { "" }, + ); + (label, transparent) + }; + + // Each distinct geometry model is read, parsed, and tallied exactly once; the cache keeps its + // (family, transparent) list per submesh for the per-pair lines. `None` = unresolvable. + let mut geo_cache: BTreeMap>> = BTreeMap::new(); + let mut fam_transparent: BTreeMap = BTreeMap::new(); + let mut fam_opaque: BTreeMap = BTreeMap::new(); + let mut rungs: BTreeMap = BTreeMap::new(); + let mut unresolved: Vec = Vec::new(); + let mut parse_failures: Vec<(String, String)> = Vec::new(); + let (mut scanned, mut owners) = (0u32, 0u32); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + if benilla_formats::parse_m2_particle_emitters(&bytes).is_err() { + continue; + } + // Distinct geometry references, normalized the way the chain resolves model paths + // (lowercase, `\` separators, `.mdx`/`.mdl` → `.m2` — `model_key` mirrors the crate's own + // `model_path`). + let geos: BTreeSet = record_extras(&bytes) + .iter() + .filter_map(|e| e.geometry_model.as_deref()) + .map(|p| model_key(&p.replace('/', "\\"))) + .collect(); + if geos.is_empty() { + continue; + } + owners += 1; + // The rung the owner's shards are stamped with: the renderer's own bound and rung, at + // placement scale 1 (a scaled placement multiplies the reach). + let dir = name.rsplit_once('\\').map_or("", |(d, _)| d); + let owner_subs = + benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default(); + let reach = benilla_formats::m2_owner_reach(&owner_subs); + let rung = benilla_formats::owner_last_rung(reach); + *rungs.entry(rung as u32).or_default() += 1; + for geo in geos { + if !geo_cache.contains_key(&geo) { + let parsed = match chain.read_file(&geo) { + Ok(gb) => { + let gdir = geo.rsplit_once('\\').map_or("", |(d, _)| d); + match benilla_formats::parse_m2_render_submeshes(&gb, gdir, &[]) { + Ok(gsubs) => { + let fams: Vec<(String, bool)> = gsubs.iter().map(&fam).collect(); + for (label, transparent) in &fams { + let tally = if *transparent { + &mut fam_transparent + } else { + &mut fam_opaque + }; + *tally.entry(label.clone()).or_default() += 1; + } + Some(fams) + } + Err(e) => { + parse_failures.push((geo.clone(), e.to_string())); + None + } + } + } + Err(_) => { + unresolved.push(geo.clone()); + None + } + }; + geo_cache.insert(geo.clone(), parsed); + } + let fams = match &geo_cache[&geo] { + Some(fams) if fams.is_empty() => "(no submeshes)".to_string(), + Some(fams) => fams + .iter() + .map(|(l, _)| l.as_str()) + .collect::>() + .into_iter() + .collect::>() + .join(", "), + None => "UNRESOLVED".to_string(), + }; + println!("rung {rung:>2.0} reach {reach:>8.2} {name} -> {geo} [{fams}]"); + } + } + + eprintln!( + "{scanned} models scanned\n \ + {owners} own a geometry-model (3-D shard) emitter\n \ + {} distinct geometry models referenced\n \ + {} geometry paths unresolvable in the chain", + geo_cache.len(), + unresolved.len() + ); + eprintln!(" owner rung histogram (placement scale 1):"); + for (rung, n) in &rungs { + eprintln!(" rung {rung:>2} {n:>5}"); + } + eprintln!(" geometry family tuples — TRANSPARENT pass (submeshes across distinct models):"); + for (label, n) in &fam_transparent { + eprintln!(" {label:<24} {n:>5}"); + } + eprintln!(" geometry family tuples — opaque/mask pass:"); + for (label, n) in &fam_opaque { + eprintln!(" {label:<24} {n:>5}"); + } + if !unresolved.is_empty() { + eprintln!(" unresolvable geometry paths:"); + for p in &unresolved { + eprintln!(" {p}"); + } + } + if !parse_failures.is_empty() { + eprintln!(" geometry parse failures:"); + for (p, e) in &parse_failures { + eprintln!(" {p}: {e}"); + } + } + Ok(()) +} + +/// Sweep every `.m2` (optionally under a path prefix) and count, per model, the two halves of the +/// **owner-last draw-order** law: the EFFECTS a model authors (particle emitters + ribbon trails) +/// and the TRANSPARENT-pass batches of its own body those effects must draw after (decisions +/// 0719/0721). A model with both is one the rung actually changes; a model with effects and no +/// transparent batch of its own never had the defect and is listed only in the totals. +/// +/// This is the population instrument the two decisions were argued from. "Does this fix anything +/// besides the voidwalker's eyes?" is not a question to answer by naming plausible creatures — +/// it is a count, and the count is what says whether the mechanism closes a class or a case. +pub fn fxordercensus(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + // Per top-level content family (Creature / Item / Spells / World / …), so the totals say + // WHERE the class lives rather than only how big it is. + let mut family: BTreeMap = BTreeMap::new(); + let (mut scanned, mut with_fx, mut at_risk) = (0u32, 0u32, 0u32); + let mut rungs: BTreeMap = BTreeMap::new(); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let emitters = benilla_formats::parse_m2_particle_emitters(&bytes) + .map(|e| e.len()) + .unwrap_or(0); + let trails = benilla_formats::m2_ribbon_emitter_count(&bytes); + if emitters + trails == 0 { + continue; + } + with_fx += 1; + // The occluders: batches the renderer puts in the one distance-sorted transparent list. + // `additive` forces that pass whatever the authored blend says (`model_material`), so the + // test mirrors the renderer's rather than reading the blend word alone. + let dir = name.rsplit_once('\\').map_or("", |(d, _)| d); + let subs = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default(); + let occluders: Vec<&benilla_formats::RenderSubmesh> = subs + .iter() + .filter(|s| { + s.additive + || matches!( + s.blend, + benilla_formats::ModelBlend::Blend + | benilla_formats::ModelBlend::Mod + | benilla_formats::ModelBlend::Mod2x + ) + }) + .collect(); + let transparent = occluders.len(); + // The renderer's own bound and the renderer's own rung — not a re-derivation of them. + let reach = benilla_formats::m2_owner_reach(&subs); + let fam = name.split('\\').next().unwrap_or("?").to_string(); + family.entry(fam).or_default().0 += 1; + if transparent == 0 { + continue; // effects, but nothing of its own that could paint over them + } + at_risk += 1; + family + .entry(name.split('\\').next().unwrap_or("?").to_string()) + .or_default() + .1 += 1; + // At placement scale 1 — the survey number; a scaled placement multiplies the reach. + let rung = benilla_formats::owner_last_rung(reach); + *rungs.entry(rung as u32).or_default() += 1; + println!( + "{transparent:>3} transp {emitters:>2} emit {trails:>2} ribb reach {reach:>8.2} \ + rung {rung:>2.0} {name}" + ); + } + eprintln!( + "{scanned} models scanned\n \ + {with_fx} author effects (emitters and/or ribbons)\n \ + {at_risk} of those ALSO author transparent batches of their own — the population the \ + owner-last rung changes" + ); + eprintln!(" by family (with-effects / at-risk):"); + for (fam, (n, risk)) in &family { + eprintln!(" {fam:<24} {n:>5} / {risk:>5}"); + } + eprintln!(" rung distribution (at-risk models, placement scale 1):"); + for (rung, n) in &rungs { + eprintln!(" rung {rung:>2} {n:>5}"); + } + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/sequence.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/sequence.rs new file mode 100644 index 00000000..a376f4f8 --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/sequence.rs @@ -0,0 +1,524 @@ +//! Corpus scans over **which sequence is playing** — the animation-arm side, where being on the +//! wrong sequence (or on none at all) silently mis-renders everything keyed to it. +//! +//! The GameObject substate arm (`goanimscan`), the effect-model Stand/Hold/Decay lifecycle +//! (`fxlifescan`), the loader-idle slot the per-sequence bakes degrade away from (`idleslotscan`), +//! and the models with no keyed bone at all, so no clock ever advances (`seqclockscan`). + +use std::collections::{BTreeMap, HashSet}; + +use anyhow::{Context, Result}; +use benilla_formats::Chain; + +use crate::model_key; + +/// 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(()) +} + +/// `AnimationData.dbc` ids of the effect-model lifecycle triple (names read from the real DBC). +const ANIM_STAND: u16 = 0; +const ANIM_HOLD: u16 = 158; +const ANIM_DECAY: u16 = 159; + +/// Sweep every `.m2` (optionally under a path prefix) and census the **effect-model animation +/// lifecycle**: which models author the `Stand`(0) → `Hold`(158) → `Decay`(159) triple, and what a +/// consumer that arms ONE sequence and never advances would render for each. +/// +/// The reference arms an effect instance's default track — `animationLookup[0]`, i.e. `Stand` — and +/// a model that also owns `Hold` authors `Stand` as a **birth** (grow-in, clamp flag set) with the +/// sustained pulse living in the separate looping `Hold` sequence, and the fade-out in `Decay`. +/// A one-sequence consumer therefore FREEZES on the last frame of the birth for the effect's whole +/// life — no pulse, no fade — which is exactly what "the Ice Barrier shield is frozen" looks like. +/// +/// The classification is that failure, made countable: +/// - **FREEZE** — owns `Hold`, and the armed `Stand` clamps: the reference pulses, we hold a pose. +/// - **hold-loops** — owns `Hold` but `Stand` itself loops: still wrong (the wrong clip), but moving. +/// - **decay-only** — owns `Decay` and no `Hold`: only the reap leg is unrendered. +/// +/// It also counts the **file-order divergence**: the reference arms `animationLookup[0]`, so a +/// consumer that arms the file's *first slot* instead is additionally wrong on any model whose slot +/// 0 is not its `Stand` (the `DuelingFlag.m2` Spawn/Stand/Despawn shape, decision 0637). The two +/// bugs are independent and the closing line separates them. +/// +/// The population instrument for the mechanism, so it is closed corpus-wide rather than spell by +/// spell; `m2seq` then explains one model in full. +pub fn fxlifescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + let (mut scanned, mut freeze, mut hold_loops, mut decay_only) = (0u32, 0u32, 0u32, 0u32); + let (mut no_stand, mut slot0_not_stand) = (0u32, 0u32); + let mut by_dir: BTreeMap = BTreeMap::new(); + let mut rows: Vec<(String, String, String, f32, f32, f32)> = Vec::new(); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let seqs = benilla_formats::parse_m2_animations(&bytes); + if seqs.is_empty() { + continue; + } + // What the reference arms: `animationLookup[0]` — Stand — never the file-order-first slot + // (they diverge on any model whose sequence 0 is not its Stand). + let armed = seqs.iter().find(|s| s.anim_id == ANIM_STAND); + let hold = seqs.iter().find(|s| s.anim_id == ANIM_HOLD); + let decay = seqs.iter().find(|s| s.anim_id == ANIM_DECAY); + if hold.is_none() && decay.is_none() { + continue; // neither leg authored — no lifecycle to miss + } + let class = match (hold.is_some(), armed) { + (true, Some(a)) if !a.looping => { + freeze += 1; + "FREEZE" + } + (true, _) => { + hold_loops += 1; + "hold-loops" + } + (false, _) => { + decay_only += 1; + "decay-only" + } + }; + // The file-order divergence, independent of the lifecycle bug: what a slot-0 consumer arms + // versus the reference's `animationLookup[0]`. + let arm = match (armed, seqs[0].anim_id) { + (None, first) => { + no_stand += 1; + format!("no-Stand(slot0={first})") + } + (Some(_), ANIM_STAND) => "slot0".to_string(), + (Some(_), first) => { + slot0_not_stand += 1; + format!("slot0={first}!") + } + }; + let top = name.split_once('\\').map(|(d, _)| d).unwrap_or(""); + *by_dir.entry(top.to_ascii_lowercase()).or_default() += 1; + rows.push(( + name, + class.to_string(), + arm, + armed.map_or(0.0, |a| a.duration), + hold.map_or(0.0, |s| s.duration), + decay.map_or(0.0, |s| s.duration), + )); + } + rows.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0))); + println!( + "model class arm stand hold decay" + ); + for (name, class, arm, stand, hold, decay) in rows.iter().take(80) { + println!("{name:<60} {class:<10} {arm:<10} {stand:>5.2} {hold:>5.2} {decay:>5.2}"); + } + if rows.len() > 80 { + println!("… and {} more", rows.len() - 80); + } + println!( + "\n{} of {scanned} models author a Hold(158)/Decay(159) lifecycle leg", + rows.len() + ); + println!(" FREEZE {freeze:>5} (owns Hold; the armed Stand clamps — a pose, where the reference pulses)"); + println!(" hold-loops {hold_loops:>5} (owns Hold; the armed Stand loops — moving, but the wrong clip)"); + println!(" decay-only {decay_only:>5} (owns Decay only — just the reap leg unrendered)"); + println!( + "of those, the file-order divergence (a slot-0 consumer arms the wrong sequence outright): \ + {slot0_not_stand} with slot 0 != Stand, {no_stand} with no Stand at all" + ); + // Named in full, never left to the row cap: this set is small and each member is a distinct + // "arms the wrong sequence from frame one" case. + for (name, _, arm, ..) in rows.iter().filter(|r| r.2 != "slot0") { + println!(" {name:<58} {arm}"); + } + println!("by top-level directory:"); + for (dir, n) in &by_dir { + println!(" {dir:<16} {n:>5}"); + } + Ok(()) +} + +/// Sweep every `.m2` (optionally under a path prefix) and list the models where the **loader-idle +/// sequence is not file slot 0** while benilla's render content gate declines to arm it — so every +/// per-sequence bake (`EmitTiming`/`EmitParams`/`AlphaAnim`) degrades to slot 0, a sequence the +/// instance is not playing (decision 0936, found on the Stormwind battlefield banner). +/// +/// The reference arms the loader-idle sequence on **every** M2 instance at load (`0x70ebd0`'s tail, +/// wow-re `gameobject-anim-arm.md` §1), so "which sequence is this playing" always has an answer. +/// benilla skips the arm when looping the idle would render identically to the static mesh — sound +/// for the *mesh*, and silently wrong for anything keyed on the sequence *identity*. The two only +/// disagree visibly when the idle is not slot 0, which is the `DuelingFlag` shape: +/// `Spawn(145) / Stand(0) / Despawn(157)`, where slot 0 is the **Spawn flourish**. +/// +/// Reports, per model: the emitters whose enabled/rate gate at t = 0 differs between slot 0 and the +/// idle slot (`FX`), and the batches whose combined material-alpha factor differs (`ALPHA`). +pub fn idleslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + let (mut scanned, mut unarmed, mut window) = (0u32, 0u32, 0u32); + let (mut fx_models, mut alpha_models) = (0u32, 0u32); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + let anims = benilla_formats::parse_m2_animations(&bytes); + if anims.is_empty() { + continue; + } + scanned += 1; + let idle_id = benilla_formats::parse_m2_playable_animation_lookup(&bytes) + .unwrap_or_default() + .first() + .map_or(0, |p| p.resolved_id); + // benilla's render content gate (`benilla_assets`' `idle_pose_differs`): a bind-pose idle + // is not armed at all, so nothing ever overrides the per-sequence bakes' opening slot. + if anims + .iter() + .any(|a| a.anim_id == idle_id && !a.is_rest_pose()) + { + continue; + } + unarmed += 1; + let idle = anims + .iter() + .find(|a| a.anim_id == idle_id) + .map_or(0, |a| a.seq_index); + if idle == 0 { + continue; // degrading to slot 0 happens to BE the idle slot — no divergence + } + window += 1; + let mut lines = Vec::new(); + let defs = benilla_formats::parse_m2_particle_emitters(&bytes).unwrap_or_default(); + let mut fx = 0u32; + for (i, d) in defs.iter().enumerate() { + let on = |s: usize| { + d.timing.emitting(Some(s), 0.0, 0.0) && d.timing.rate(Some(s), 0.0, 0.0) > 0.0 + }; + if on(0) != on(idle) { + fx += 1; + lines.push(format!( + " FX emitter {i:>2}: slot 0 {:>3}, idle slot {idle} {:>3} tex {}", + if on(0) { "ON" } else { "off" }, + if on(idle) { "ON" } else { "off" }, + d.texture.as_deref().unwrap_or("NONE"), + )); + } + } + let dir = name.rsplit_once('\\').map_or("", |(d, _)| d); + let subs = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]).unwrap_or_default(); + let mut alpha = 0u32; + for (i, s) in subs.iter().enumerate() { + let Some(a) = &s.alpha_anim else { continue }; + let (f0, fi) = (a.sample(Some(0), 0.0, 0.0), a.sample(Some(idle), 0.0, 0.0)); + if (f0 - fi).abs() > 1e-3 { + alpha += 1; + lines.push(format!( + " ALPHA batch {i:>2}: slot 0 {f0:.2}, idle slot {idle} {fi:.2}" + )); + } + } + if !lines.is_empty() { + fx_models += u32::from(fx > 0); + alpha_models += u32::from(alpha > 0); + println!("{name} (idle slot {idle} of {} sequences)", anims.len()); + for l in lines { + println!("{l}"); + } + } + } + eprintln!( + "{scanned} models with sequences, {unarmed} whose idle the content gate leaves unarmed, \ + {window} of those with idle slot != 0 (the divergence window): \ + {fx_models} model(s) whose EMITTERS gate differently, \ + {alpha_models} whose MATERIAL ALPHA differs" + ); + Ok(()) +} + +/// Sweep every `.m2` (optionally under a path prefix) and list the models whose animation is +/// authored **entirely outside the bone tracks** — sequences exist, per-sequence consumers exist +/// (particle emitters, ribbons, material alpha/colour, UV transforms), and **not one sequence +/// keys a bone**. +/// +/// That combination is the blind spot of a renderer whose sequence clock rides a *bone* animation +/// clip: with no keyed bone there is no clip, so there is no player, so nothing ever says which +/// sequence is playing or how far into it — and every per-sequence consumer degrades to "file slot +/// 0, at t = 0", permanently. The emitters still build, pool and tick; they just read the wrong +/// column of a table that never advances. Found on the Molten Core rune + flame ring (decision +/// 0941): the rune's slot 0 is its *Closed* band, where every emission rate key is 0 (no flames at +/// all), and the ring's spline spawn-window opens `0 → 1` over its first second, so frozen at t=0 +/// all 180 particles are born at one point of a 2.8-yd circle — one bright blob. +/// +/// The `[GO]` mark is the cross-tab that matters: a `GameObjectDisplayInfo` model reaches the +/// world through the **hosted** clock (`EmitClock::Host`), which is the lane that freezes at +/// `t = 0`. A placed doodad of the same shape still runs its spawn-age clock (only its *slot* is +/// pinned — decision 0760's axis), so it is wrong in a different, milder way. +/// +/// PARTIAL models — some sequences key bones, some don't — are tallied, not listed: there the +/// clock exists, but the unkeyed sequences are unreachable (no clip to arm), so a GameObject +/// substate or a creature animation id that resolves to one plays nothing. +pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + // Which models a GameObject can display — the hosted-clock lane. + let go_models: HashSet = benilla_formats::load_gameobject_catalog(chain) + .map(|c| c.iter().map(|(_, p)| model_key(p)).collect()) + .unwrap_or_default(); + let names = super::m2_names(chain, prefix)?; + let (mut scanned, mut with_seqs, mut frozen, mut frozen_go, mut partial, mut inert) = + (0u32, 0u32, 0u32, 0u32, 0u32, 0u32); + let mut frozen_emitters = 0usize; + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + scanned += 1; + let seqs = benilla_formats::parse_m2_animations(&bytes); + if seqs.is_empty() { + continue; + } + with_seqs += 1; + // Exactly the runtime's own test (`build_animation_clip`): a sequence becomes a clip iff + // some bone track produced a key inside its band. + let keyed = seqs + .iter() + .filter(|a| { + a.bones.iter().any(|b| { + !b.translation.is_empty() || !b.rotation.is_empty() || !b.scale.is_empty() + }) + }) + .count(); + // Counted over EVERY model with sequences, consumers or not: it measures clip + // REACHABILITY (can an arm find this sequence at all), which is a property of the model. + if keyed > 0 && keyed < seqs.len() { + partial += 1; + } + let Ok(s) = benilla_formats::parse_m2_animation_summary(&bytes) else { + continue; + }; + // The per-sequence consumers: everything that samples a track on the playing sequence's + // clock. A constant (≤1 key) colour/alpha track is excluded — it reads the same in every + // slot, so a frozen clock costs it nothing. + let consumers = s.particle_emitter_count + + s.ribbon_emitter_count + + s.color_alpha_tracks.1 + + s.color_rgb_tracks.1 + + s.transparency_tracks.1 + + s.texture_transform_count; + if keyed == 0 && consumers == 0 { + // The same shape with nothing to sample: a clock costs it nothing and buys it + // nothing. Counted because it IS the cost side of giving every sequenced model a + // clock — these are the models that gain an inert one. + inert += 1; + } + if consumers > 0 && keyed == 0 { + frozen += 1; + frozen_emitters += s.particle_emitter_count; + let is_go = go_models.contains(&name.to_ascii_lowercase()); + if is_go { + frozen_go += 1; + } + println!( + "{}{name}\n seqs {:>3} (0 keyed) · emitters {:>2} ribbons {} · alpha {} rgb {} \ + transp {} · uvanim {} · gseq {}", + if is_go { "[GO] " } else { " " }, + seqs.len(), + s.particle_emitter_count, + s.ribbon_emitter_count, + s.color_alpha_tracks.1, + s.color_rgb_tracks.1, + s.transparency_tracks.1, + s.texture_transform_count, + s.global_seq_channels.len(), + ); + } + } + eprintln!( + "{scanned} models scanned, {with_seqs} with sequences: {frozen} CLOCKLESS \ + ({frozen_emitters} emitters) — sequences + per-sequence consumers, not one keyed bone; \ + {frozen_go} of them GameObject display models (the hosted lane, frozen at slot 0 t=0). \ + {partial} PARTIAL models have unkeyed sequences a clip lookup can never reach; \ + {inert} more are boneless with nothing to sample (a clock there is inert)." + ); + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/skeleton.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/skeleton.rs new file mode 100644 index 00000000..9351c94f --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/skeleton.rs @@ -0,0 +1,583 @@ +//! Corpus scans over the **bone tree and what addresses it** — the skeleton's own tracks, and +//! the attachment table things hang off. +//! +//! `bonescan` measures our skeletal parse against the reference's sampler band by band; +//! `attachscan` censuses where a table scan disagrees with the reference's `attachLookup`. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::Result; +use benilla_formats::Chain; + +use crate::model_key; + +/// Sweep every `.m2` and census its **attachment addressing**: how many records it authors, how +/// many attach ids its AttachLookup resolves, and — the point of the report — where the two +/// disagree, i.e. where "scan the table for a record with this id" answers differently from the +/// reference's `lookup[id]` (`0x710310`). +/// +/// Built for decision 0805 (item glows hang on the item model's ids 0..4) to answer the question +/// that decides whether the lookup can be adopted globally: which models change hands? One line +/// per divergent model — `+id` = an id only the lookup reaches, `-id` = an id only a table scan +/// reaches (a record the reference cannot address at all), `id:a→b` = same id, different record. +pub fn attachscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + let (mut scanned, mut with_points, mut divergent) = (0u32, 0u32, 0u32); + let mut by_family: BTreeMap = BTreeMap::new(); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + let Ok(format) = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes)) else { + continue; + }; + let model = format.model(); + scanned += 1; + if model.attachments.is_empty() && model.attach_lookup.is_empty() { + continue; + } + with_points += 1; + // What a table scan would answer (first record per id — the pre-0805 rule) against what + // the lookup answers, compared by the record each id lands on. + let mut scan: BTreeMap = BTreeMap::new(); + for (i, a) in model.attachments.iter().enumerate() { + scan.entry(a.id).or_insert(i); + } + let lookup: BTreeMap = (0..model.attach_lookup.len()) + .filter_map(|id| { + let idx = *model.attach_lookup.get(id)?; + (idx != 0xffff && (idx as usize) < model.attachments.len()) + .then_some((id as u16, idx as usize)) + }) + .collect(); + let mut diffs: Vec = Vec::new(); + for (&id, &idx) in &lookup { + match scan.get(&id) { + Some(&s) if s != idx => diffs.push(format!("{id}:{s}→{idx}")), + Some(_) => {} + None => diffs.push(format!("+{id}")), + } + } + for &id in scan.keys() { + if !lookup.contains_key(&id) { + diffs.push(format!("-{id}")); + } + } + if !diffs.is_empty() { + divergent += 1; + *by_family.entry(model_key(&name)).or_default() += 1; + println!( + "{:<62} recs {:>2} lookup {:>2} {}", + name, + model.attachments.len(), + lookup.len(), + diffs.join(" ") + ); + } + } + eprintln!( + "{scanned} models scanned, {with_points} with attachment data, \ + {divergent} where a table scan disagrees with the lookup" + ); + for (family, n) in by_family { + eprintln!(" {family}: {n}"); + } + Ok(()) +} + +/// One bone `M2Track` read straight off the file bytes (v256 stride `0x1c`: interp@0, gseq@2, +/// interpolation_ranges `M2Array`@0x04/0x08, timestamps@0x0c/0x10, values@0x14/0x18). +/// +/// Deliberately raw rather than via `parse_m2_animations`: this instrument's whole job is to +/// compare what the **file** says against what our parser currently emits, so it must not go +/// through the parser under test. +struct RawBoneTrack { + interp: u16, + gseq: u16, + ranges: Vec<(u32, u32)>, + ts: Vec, + vals: Vec<[f32; 4]>, + comps: usize, +} + +impl RawBoneTrack { + fn read(b: &[u8], o: usize, comps: usize) -> Option { + let u32_at = |p: usize| -> Option { + b.get(p..p + 4) + .map(|s| u32::from_le_bytes(s.try_into().unwrap())) + }; + let u16_at = |p: usize| -> Option { + b.get(p..p + 2) + .map(|s| u16::from_le_bytes(s.try_into().unwrap())) + }; + let f32_at = |p: usize| -> Option { + b.get(p..p + 4) + .map(|s| f32::from_le_bytes(s.try_into().unwrap())) + }; + let (interp, gseq) = (u16_at(o)?, u16_at(o + 2)?); + let (rn, ro) = (u32_at(o + 0x04)? as usize, u32_at(o + 0x08)? as usize); + let (tn, to) = (u32_at(o + 0x0c)? as usize, u32_at(o + 0x10)? as usize); + let (vn, vo) = (u32_at(o + 0x14)? as usize, u32_at(o + 0x18)? as usize); + let ranges = (0..rn) + .map_while(|i| Some((u32_at(ro + i * 8)?, u32_at(ro + i * 8 + 4)?))) + .collect(); + let stride = comps * 4; + let n = tn.min(vn); + let mut ts = Vec::with_capacity(n); + let mut vals = Vec::with_capacity(n); + for i in 0..n { + let Some(t) = u32_at(to + i * 4) else { break }; + let mut v = [0.0f32; 4]; + let mut ok = true; + for (c, slot) in v.iter_mut().take(comps).enumerate() { + match f32_at(vo + i * stride + c * 4) { + Some(f) => *slot = f, + None => ok = false, + } + } + if !ok { + break; + } + ts.push(t); + vals.push(v); + } + Some(Self { + interp, + gseq, + ranges, + ts, + vals, + comps, + }) + } + + /// The reference's sample at absolute time `t_ms` for sequence file slot `slot` — FN1 + /// (`0x713d50`) verbatim, then the sampler's own lerp/step leg (wow-re `eval.md` FN1/FN2/FN6). + fn reference(&self, slot: usize, t_ms: u32) -> Option<[f32; 4]> { + let last = self.ts.len().checked_sub(1)?; + // FN1 §1: the window is `ranges[slot]` when the array is present, else the whole key list. + let (lo, hi) = match self.ranges.get(slot) { + Some(&(lo, hi)) => (lo as usize, hi as usize), + None => (0, last), + }; + let (lo, hi) = (lo.min(last), hi.min(last)); + // FN1 §2: a collapsed window resolves to `keys[lo]` outright. + if lo >= hi { + return Some(self.vals[lo]); + } + let mut k0 = lo; + for (k, &ts) in self.ts.iter().enumerate().take(hi + 1).skip(lo) { + if ts <= t_ms { + k0 = k; + } else { + break; + } + } + // FN1 §5: `k1 = k0+1`, bounded by the TOTAL key count — never by the window's `hi`. + if self.interp == 0 || k0 + 1 > last { + return Some(self.vals[k0]); + } + let (t0, t1) = (self.ts[k0], self.ts[k0 + 1]); + if t1 <= t0 { + return Some(self.vals[k0]); + } + let f = (t_ms as f32 - t0 as f32) / (t1 as f32 - t0 as f32); + let (a, b) = (self.vals[k0], self.vals[k0 + 1]); + let mut out = [0.0f32; 4]; + for (i, slot) in out.iter_mut().enumerate() { + *slot = a[i] + (b[i] - a[i]) * f; + } + Some(out) + } + + /// What `models::anim::read_bone_track` emits for this band today: the in-band keys, or — when + /// the band holds none — the single **nearest** out-of-band key (benilla decision 0133). + /// Returns `(value_at_band_start, value_at_band_end, band_was_empty)`. + fn benilla_today(&self, start: u32, end: u32) -> Option<([f32; 4], [f32; 4], bool)> { + let inb: Vec = (0..self.ts.len()) + .filter(|&k| self.ts[k] >= start && self.ts[k] <= end) + .collect(); + if let (Some(&f), Some(&l)) = (inb.first(), inb.last()) { + // Bevy holds the first key before it and the last key after it. + return Some((self.vals[f], self.vals[l], false)); + } + let mut best: Option<(u32, usize)> = None; + for (k, &ts) in self.ts.iter().enumerate() { + let d = if ts < start { start - ts } else { ts - end }; + if best.is_none_or(|(bd, _)| d < bd) { + best = Some((d, k)); + } + } + let (_, k) = best?; + Some((self.vals[k], self.vals[k], true)) + } + + /// Distance between two sampled values in the channel's own units: **degrees** of rotation for + /// a quaternion (numerically stable near identity — `acos(dot)` has a ~0.04° floor in f32), + /// **model units** for a translation/scale vector. + fn delta(&self, a: [f32; 4], b: [f32; 4]) -> f32 { + if self.comps == 4 { + let norm = |q: [f32; 4]| { + let n = (q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3]).sqrt(); + if n > 0.0 { + [q[0] / n, q[1] / n, q[2] / n, q[3] / n] + } else { + q + } + }; + let (a, mut b) = (norm(a), norm(b)); + if a.iter().zip(b).map(|(x, y)| x * y).sum::() < 0.0 { + b = [-b[0], -b[1], -b[2], -b[3]]; + } + let d = a + .iter() + .zip(b) + .map(|(x, y)| (x - y) * (x - y)) + .sum::() + .sqrt(); + 2.0 * (d / 2.0).min(1.0).asin().to_degrees() + } else { + (0..3) + .map(|i| (a[i] - b[i]) * (a[i] - b[i])) + .sum::() + .sqrt() + } + } +} + +/// Sweep every `.m2` (optionally under a path prefix) and measure, per bone track and per sequence +/// band, how far **our** skeletal parse sits from the **reference's** sampler — the population +/// instrument behind benilla decision 0133's named residual ("an empty band clamps to the nearest +/// authored key … a named approximation of the mid-gap lerp"). +/// +/// Three separately-reported disagreements, each a distinct mechanism (wow-re `eval.md`): +/// +/// - **EMPTY bands** — a band with no keys of its own. We hold the nearest authored key; the +/// reference holds `keys[ranges[slot].lo]`, or lerps across the bracket when the window spans two +/// keys. The 0133 residual proper. +/// - **HELD edges** — a keyed band whose first key is late / last key is early. Bevy holds the edge +/// key; the reference keeps interpolating toward the neighbouring **out-of-band** key, because +/// FN1's `k1 = k0+1` is bounded by the total key count and not by the window. +/// - **STEP tracks** — `interpolation_type == 0`. The reference's samplers branch on it and copy +/// `keys[k0]` with no interpolation; our bone parse emits keys and lets Bevy interpolate, so a +/// snap becomes a glide. +/// +/// Plus the safety check the whole idea rests on: whether any band's own keys fall **outside** the +/// window `ranges[slot]` would search (they never do — if they did, adopting the window would +/// reintroduce the garbage pose 0133 records). +pub fn bonescan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + // Above the f32 noise floor of the stable angle formula (~1e-4°) by two orders, and far below + // anything an eye could catch — a slot over this is a real authored difference. + const ROT_EPS: f32 = 0.01; // degrees + const VEC_EPS: f32 = 1e-4; // model units + let (mut scanned, mut models_step, mut models_empty_differ, mut models_edge_differ) = + (0u32, 0u32, 0u32, 0u32); + let (mut slots_keyed, mut slots_empty) = (0u64, 0u64); + let (mut empty_differ, mut edge_differ, mut ranges_absent, mut window_violation) = + (0u64, 0u64, 0u64, 0u64); + let (mut step_tracks, mut total_tracks, mut step_bands) = (0u64, 0u64, 0u64); + let (mut gseq_tracks, mut gseq_multi, mut gseq_orphan) = (0u64, 0u64, 0u64); + let (mut gseq_ranges_absent, mut gseq_ranges_restrict) = (0u64, 0u64); + let mut gseq_orphan_models: BTreeSet = BTreeSet::new(); + let mut gseq_restrict_models: BTreeSet = BTreeSet::new(); + let mut emitted_extra = 0u64; + // (empty-band peak, keyed-edge peak) per channel kind — the bound, not a threshold count. + let (mut peak_rot, mut peak_vec) = ((0.0f32, 0.0f32), (0.0f32, 0.0f32)); + let mut emitted_models: BTreeSet = BTreeSet::new(); + // Worst offender per class: (delta, model, bone, channel, slot). + let mut worst_empty: Vec<(f32, String, usize, &'static str, usize)> = Vec::new(); + let mut worst_edge: Vec<(f32, String, usize, &'static str, usize)> = Vec::new(); + let mut step_models: BTreeMap = BTreeMap::new(); + let mut worst_step: Vec<(f32, String, usize, &'static str, usize, usize)> = Vec::new(); + for name in names { + let Ok(b) = chain.read_file(&name) else { + continue; + }; + if b.len() < 0x40 || &b[0..4] != b"MD20" { + continue; + } + scanned += 1; + let u32_at = |o: usize| -> usize { + b.get(o..o + 4) + .map(|s| u32::from_le_bytes(s.try_into().unwrap()) as usize) + .unwrap_or(0) + }; + // Sequences in FILE order (count@0x1c/ofs@0x20, stride 0x44) — the order `ranges` indexes. + let (sn, so) = (u32_at(0x1c), u32_at(0x20)); + let seqs: Vec<(u32, u32)> = (0..sn) + .map_while(|i| { + let e = so + i * 0x44; + (e + 0x44 <= b.len()).then(|| (u32_at(e + 4) as u32, u32_at(e + 8) as u32)) + }) + .collect(); + // globalSequences @0x14/0x18 — a duration per entry; 0 means the loop has no period. + let (gn, go) = (u32_at(0x14), u32_at(0x18)); + let gseq_period = |g: u16| -> u32 { + let i = g as usize; + if i >= gn { + 0 + } else { + u32_at(go + i * 4) as u32 + } + }; + let (bn, bo) = (u32_at(0x34), u32_at(0x38)); + let (mut m_step, mut m_empty, mut m_edge) = (0u64, 0u64, 0u64); + for bi in 0..bn { + let brec = bo + bi * 0x6c; + if brec + 0x6c > b.len() { + break; + } + for (off, comps, ch) in [(0x0c, 3, "trans"), (0x28, 4, "rot"), (0x44, 3, "scale")] { + let Some(tr) = RawBoneTrack::read(&b, brec + off, comps) else { + continue; + }; + if tr.ts.is_empty() { + continue; + } + total_tracks += 1; + if tr.interp == 0 { + step_tracks += 1; + m_step += 1; + // The step deviation only becomes visible when a band holds TWO keys: that is + // where the reference snaps and we glide. Measure the size of the snap. + for (slot, &(start, end)) in seqs.iter().enumerate() { + if end <= start { + continue; + } + let inb: Vec = (0..tr.ts.len()) + .filter(|&k| tr.ts[k] >= start && tr.ts[k] <= end) + .collect(); + if inb.len() < 2 { + continue; + } + step_bands += 1; + let jump = inb + .windows(2) + .map(|w| tr.delta(tr.vals[w[0]], tr.vals[w[1]])) + .fold(0.0f32, f32::max); + if jump > if comps == 4 { ROT_EPS } else { VEC_EPS } { + worst_step.push((jump, name.clone(), bi, ch, slot, inb.len())); + } + } + } + // A global-sequence track runs on its own clock. `read_bone_track` keeps the + // SINGLE-key case (a constant channel — the stowed-weapon rest quats) and leaves + // the multi-key case to `parse_m2_global_sequence_bones` → the `GlobalSeqDrive` + // lane, which needs a non-zero `globalSequences[gseq]` period. A multi-key channel + // on a ZERO-period global sequence falls between the two and is sampled by + // neither — census that gap, and the shape of the `ranges` window the reference + // would still apply here (FN1 selects the window BEFORE it resolves the gseq + // clock, so a restrictive window would clip the loop). + if tr.gseq != 0xffff { + gseq_tracks += 1; + if tr.ts.len() > 1 { + gseq_multi += 1; + if gseq_period(tr.gseq) == 0 { + gseq_orphan += 1; + gseq_orphan_models.insert(name.clone()); + } + match tr.ranges.len() { + 0 => gseq_ranges_absent += 1, + _ => { + if tr + .ranges + .iter() + .any(|&(lo, hi)| lo != 0 || hi as usize != tr.ts.len() - 1) + { + gseq_ranges_restrict += 1; + gseq_restrict_models.insert(name.clone()); + } + } + } + } + continue; + } + let eps = if comps == 4 { ROT_EPS } else { VEC_EPS }; + for (slot, &(start, end)) in seqs.iter().enumerate() { + if end <= start { + continue; + } + if tr.ranges.get(slot).is_none() { + ranges_absent += 1; + } + let Some((mine_a, mine_b, was_empty)) = tr.benilla_today(start, end) else { + continue; + }; + let (Some(ref_a), Some(ref_b)) = + (tr.reference(slot, start), tr.reference(slot, end)) + else { + continue; + }; + let d = tr.delta(mine_a, ref_a).max(tr.delta(mine_b, ref_b)); + let peak = if comps == 4 { + &mut peak_rot + } else { + &mut peak_vec + }; + if was_empty { + peak.0 = peak.0.max(d); + slots_empty += 1; + if d > eps { + empty_differ += 1; + m_empty += 1; + worst_empty.push((d, name.clone(), bi, ch, slot)); + } + } else { + peak.1 = peak.1.max(d); + slots_keyed += 1; + // Does this band's own key set sit inside the window the reference + // searches? If not, adopting the window would drop playable keys. + if let Some(&(lo, hi)) = tr.ranges.get(slot) { + let inb: Vec = (0..tr.ts.len()) + .filter(|&k| tr.ts[k] >= start && tr.ts[k] <= end) + .collect(); + if inb.first().is_some_and(|&f| (f as u32) < lo) + || inb.last().is_some_and(|&l| (l as u32) > hi) + { + window_violation += 1; + } + } + if d > eps { + edge_differ += 1; + m_edge += 1; + worst_edge.push((d, name.clone(), bi, ch, slot)); + } + } + } + } + } + // The other half of the check: what our parser ACTUALLY emits. A band-slot whose emitted + // key count exceeds its own in-band key count is one where the head/tail sample differed + // from the edge key and had to be carried — the only slots this parse changes. + for a in benilla_formats::parse_m2_animations(&b) { + for bk in &a.bones { + for (off, comps, emitted) in [ + (0x0c, 3, bk.translation.len()), + (0x28, 4, bk.rotation.len()), + (0x44, 3, bk.scale.len()), + ] { + let brec = bo + bk.bone as usize * 0x6c; + let Some(tr) = RawBoneTrack::read(&b, brec + off, comps) else { + continue; + }; + if tr.gseq != 0xffff { + continue; + } + let inb = tr + .ts + .iter() + .filter(|&&ts| ts >= a.start_ms && ts <= a.end_ms) + .count(); + if emitted > inb.max(1) { + emitted_extra += 1; + emitted_models.insert(name.clone()); + } + } + } + } + if m_step > 0 { + models_step += 1; + step_models.insert(name.clone(), m_step); + } + if m_empty > 0 { + models_empty_differ += 1; + } + if m_edge > 0 { + models_edge_differ += 1; + } + // Keep the worst-offender lists bounded without losing the tail. + for w in [&mut worst_empty, &mut worst_edge] { + if w.len() > 4096 { + w.sort_by(|a, b| b.0.total_cmp(&a.0)); + w.truncate(512); + } + } + if worst_step.len() > 4096 { + worst_step.sort_by(|a, b| b.0.total_cmp(&a.0)); + worst_step.truncate(512); + } + } + worst_empty.sort_by(|a, b| b.0.total_cmp(&a.0)); + worst_edge.sort_by(|a, b| b.0.total_cmp(&a.0)); + println!("scanned {scanned} models · {total_tracks} keyed bone tracks"); + println!("\nbone×channel×sequence slots: {slots_keyed} keyed, {slots_empty} EMPTY"); + println!(" ranges array absent for the slot: {ranges_absent}"); + println!(" band keys OUTSIDE their own ranges window: {window_violation} (must be 0 to adopt the window)"); + for (label, n, models, worst) in [ + ( + "EMPTY band (nearest-key vs the file's window)", + empty_differ, + models_empty_differ, + &worst_empty, + ), + ( + "HELD edge (hold vs the reference's ongoing lerp)", + edge_differ, + models_edge_differ, + &worst_edge, + ), + ] { + println!("\n{label}: {n} slots differ, across {models} models"); + for (d, m, bi, ch, slot) in worst.iter().take(15) { + let unit = if *ch == "rot" { "deg" } else { "u" }; + println!(" {d:9.3}{unit:<4} {m:<52} bone {bi:<4} {ch:<6} slot {slot}"); + } + } + worst_step.sort_by(|a, b| b.0.total_cmp(&a.0)); + println!( + "\nSTEP bone tracks (interp == 0, the reference copies keys[k0]): {step_tracks} of \ + {total_tracks}, in {models_step} models" + ); + println!( + " bands where a step track holds 2+ keys (we glide, the reference snaps): {step_bands}; \ + {} of them snap by more than the noise floor. Worst:", + worst_step.len() + ); + for (d, m, bi, ch, slot, n) in worst_step.iter().take(20) { + let unit = if *ch == "rot" { "deg" } else { "u" }; + println!(" {d:9.3}{unit:<4} {m:<52} bone {bi:<4} {ch:<6} slot {slot:<4} ({n} keys)"); + } + println!( + "\nGLOBAL-SEQUENCE bone tracks: {gseq_tracks} total, {gseq_multi} multi-key (the \ + `GlobalSeqDrive` lane's input; single-key ones are constants folded into every clip)" + ); + println!( + " multi-key on a ZERO-period global sequence — sampled by NEITHER lane: {gseq_orphan}, \ + in {} models", + gseq_orphan_models.len() + ); + for m in gseq_orphan_models.iter().take(10) { + println!(" {m}"); + } + println!( + " their `ranges` window: absent (whole key list) {gseq_ranges_absent}, RESTRICTIVE \ + (not [0, last] — would clip the loop) {gseq_ranges_restrict} in {} models", + gseq_restrict_models.len() + ); + for m in gseq_restrict_models.iter().take(10) { + println!(" {m}"); + } + println!( + "\nworst disagreement anywhere in the corpus (a BOUND, not a threshold count):\n \ + rotation: empty band {:.5} deg, keyed edge {:.5} deg\n \ + translation/scale: empty band {:.6} u, keyed edge {:.6} u", + peak_rot.0, peak_rot.1, peak_vec.0, peak_vec.1 + ); + println!( + "\nEMITTED clips: {emitted_extra} band-slots carry a head/tail sample beyond their own \ + in-band keys, in {} models", + emitted_models.len() + ); + for m in emitted_models.iter().take(10) { + println!(" {m}"); + } + println!(" models with any step track:"); + for (m, n) in step_models.iter().take(10) { + println!(" {n:>6} {m}"); + } + if step_models.len() > 10 { + println!(" … and {} more models", step_models.len() - 10); + } + Ok(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/world.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/world.rs new file mode 100644 index 00000000..df412f74 --- /dev/null +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/world.rs @@ -0,0 +1,538 @@ +//! Corpus scans over **placed world content** — what a WMO root or an ADT tile block puts in +//! the world, and how much of it the reference would even draw. +//! +//! One `.wmo` root's own tables (`wmodoodads`, `skyboxscan`) and one map region's placements +//! (`placescan`, `doodadscan`). How a placed prop is LIT is a separate question — [`super::lighting`]. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use anyhow::{Context, Result}; +use benilla_formats::{Chain, M2AnimSummary}; + +use crate::{model_key, yn}; + +/// Dump a WMO root's placed-prop tables: every MODD doodad with its MODS set membership and its +/// OWNING group(s) read from the group files' MODR lists — the relation the reference instantiates +/// from (`0x695aa0` loops a *visible* group's own refs, wow-re `m2-interior-doodad-base-light.md` +/// §453). A prop referenced by NO group is never created by the real client at all — the +/// divergence decision 0689 names and benilla still spawns. This answers "which props exist here, +/// who owns them, and which would the reference even draw" in one read (the B30/B32 question). +pub fn wmodoodads(chain: &mut Chain, raw_path: &str, filter: Option<&str>) -> Result<()> { + let root_path = raw_path.replace('/', "\\").to_ascii_lowercase(); + let bytes = chain + .read_file(&root_path) + .with_context(|| format!("reading WMO root '{root_path}'"))?; + let root = benilla_formats::parse_wmo_root(&bytes) + .with_context(|| format!("parsing WMO root '{root_path}'"))?; + let set_names = mods_set_names(&bytes); + + // MODD index -> the groups whose MODR reference it (the ownership relation). + let stem = root_path.strip_suffix(".wmo").unwrap_or(&root_path); + let mut owners: BTreeMap> = BTreeMap::new(); + let mut groups_read = 0u32; + for gi in 0..root.group_count() { + let group_path = format!("{stem}_{gi:03}.wmo"); + let Ok(gbytes) = chain.read_file(&group_path) else { + continue; + }; + groups_read += 1; + for r in benilla_formats::wmo_group_doodad_refs(&gbytes) { + owners.entry(r).or_default().push(gi); + } + } + + println!( + "{} doodad(s), {} set(s), {} group(s) ({} group file(s) read)", + root.doodads().len(), + root.doodad_sets().len(), + root.group_count(), + groups_read, + ); + for (si, s) in root.doodad_sets().iter().enumerate() { + let name = set_names.get(si).map(String::as_str).unwrap_or("?"); + println!( + "set {si:>2} [{:>5}..{:>5}) count {:>5} {name}", + s.start, + s.start + s.count, + s.count + ); + } + + let needle = filter.map(str::to_ascii_lowercase); + let infos = root.group_infos(); + let mut shown = 0u32; + let mut orphans_total = 0u32; + let mut orphans_shown = 0u32; + for (i, d) in root.doodads().iter().enumerate() { + let orphan = !owners.contains_key(&(i as u16)); + if orphan { + orphans_total += 1; + } + if let Some(n) = &needle { + if !model_key(&d.model).contains(n.as_str()) { + continue; + } + } + shown += 1; + if orphan { + orphans_shown += 1; + } + let sets: Vec = root + .doodad_sets() + .iter() + .enumerate() + .filter(|(_, s)| (s.start..s.start + s.count).contains(&(i as u32))) + .map(|(si, _)| si.to_string()) + .collect(); + let owner_cell = match owners.get(&(i as u16)) { + Some(gs) => gs + .iter() + .map(|&g| { + let class = match infos.get(g as usize) { + Some(gi) if gi.interior => "INT", + Some(_) => "EXT", + None => "?", + }; + format!("g{g}({class})") + }) + .collect::>() + .join(" "), + None => "ORPHAN".into(), + }; + println!( + "modd {i:>5} pos ({:>8.2}, {:>8.2}, {:>8.2}) scale {:.3} color #{:02x}{:02x}{:02x}{:02x} sets [{}] {owner_cell} {}", + d.position[0], d.position[1], d.position[2], + d.scale, + d.color[0], d.color[1], d.color[2], d.color[3], + sets.join(","), + d.model, + ); + } + match needle { + Some(_) => eprintln!( + "{shown} doodad(s) matched ({orphans_shown} ORPHAN); {orphans_total} orphan(s) among all {}", + root.doodads().len() + ), + None => eprintln!( + "{orphans_total} of {} doodad(s) are ORPHANS (in no group's MODR — the reference never instantiates these)", + root.doodads().len() + ), + } + Ok(()) +} + +/// The MODS set names (`char name[20]` per 32-byte record) — `WmoDoodadSet` keeps only the ranges, +/// so read the names off the raw root bytes here (top-level chunks are `[magic][size][data]`, magic +/// on disk reversed: MODS → `SDOM`). +fn mods_set_names(bytes: &[u8]) -> Vec { + let mut off = 0usize; + while off + 8 <= bytes.len() { + let size = u32::from_le_bytes([ + bytes[off + 4], + bytes[off + 5], + bytes[off + 6], + bytes[off + 7], + ]) as usize; + let Some(data_end) = (off + 8).checked_add(size) else { + return Vec::new(); + }; + if data_end > bytes.len() { + return Vec::new(); + } + if &bytes[off..off + 4] == b"SDOM" { + return bytes[off + 8..data_end] + .chunks_exact(32) + .map(|rec| { + let name = &rec[..20]; + let end = name.iter().position(|&b| b == 0).unwrap_or(20); + String::from_utf8_lossy(&name[..end]).into_owned() + }) + .collect(); + } + off = data_end; + } + Vec::new() +} + +/// Sweep every WMO **root** in the chain and report the two halves of the skybox mechanism: which +/// roots author a **MOSB** skybox model, and which carry groups flagged `0x40000` +/// ([`benilla_formats::WmoGroupInfo::show_skybox`]). +/// +/// This is the instrument that *identifies* the flag — and **exactly how far that identification +/// reaches is the point**. `0x40000` is undocumented, so the cross-tab is what establishes it means +/// anything at all: the bit never appears on a group whose root names no skybox, across all 815 +/// roots. That is a one-way implication, `flag ⇒ MOSB`, and the summary prints it so the claim is +/// re-checkable in one command rather than trusted from a decision record. +/// +/// **It does not, and cannot, say which group the RENDERER tests** — and reading it as if it did is +/// the mistake decision 0767 made (superseded by 0773). The carved law is that `0x40000` is tested +/// inside the portal flood (`0x6b42e0` in `0x6b41c0`) on the group being *visited*, so the predicate +/// is "any flood-reached group carries the bit". A census over static asset bytes has no way to see +/// that distinction; only the binary did. +/// +/// It is also the population instrument for the mechanism: which buildings in 1.12 replace the +/// `Light.dbc` gradient dome with an authored sky, and how much of each one does it. Stratholme's +/// city shell sets the bit on 61 of its 83 groups; the only other roots that set it at all are the +/// four Caverns of Time shells, which ship in the 5875 data with no 1.12 instance to enter. +pub fn skyboxscan(chain: &mut Chain) -> Result<()> { + // Roots only, because only the root carries MOSB/MOGI. + let names = super::wmo_roots(chain, None)?; + + // The cross-tab that identifies the flag: roots with/without a MOSB × groups with/without 0x40000. + let (mut both, mut mosb_only, mut flag_only, mut neither) = (0u32, 0u32, 0u32, 0u32); + let mut scanned = 0u32; + let mut hits: Vec<(String, String, usize, usize)> = Vec::new(); + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + let Ok(root) = benilla_formats::parse_wmo_root(&bytes) else { + continue; // a group file that slipped the name filter, or a truncated root + }; + scanned += 1; + let groups = root.group_infos(); + let flagged = groups.iter().filter(|g| g.show_skybox).count(); + match (root.skybox(), flagged > 0) { + (Some(sky), true) => { + both += 1; + hits.push((name, sky.to_string(), flagged, groups.len())); + } + (Some(sky), false) => { + mosb_only += 1; + hits.push(( + name, + format!("{sky} (NO group sets 0x40000)"), + 0, + groups.len(), + )); + } + (None, true) => { + flag_only += 1; + hits.push((name, "(no MOSB)".into(), flagged, groups.len())); + } + (None, false) => neither += 1, + } + } + + hits.sort(); + println!("{:<62} {:>7} skybox model", "WMO root", "groups"); + for (name, sky, flagged, total) in &hits { + println!("{name:<62} {flagged:>3}/{total:<3} {sky}"); + } + println!(); + println!("{scanned} WMO root(s) scanned"); + println!(" MOSB skybox AND >=1 group with 0x40000 : {both}"); + println!(" MOSB skybox but NO group with 0x40000 : {mosb_only}"); + println!(" group(s) with 0x40000 but NO MOSB : {flag_only}"); + println!(" neither : {neither}"); + if flag_only == 0 { + println!( + "\n=> 0x40000 NEVER appears without a MOSB ({flag_only} counter-examples in {scanned} \ + roots), and {mosb_only} root(s) name a skybox no group asks for. So the bit is real and \ + both halves matter — but this census establishes only 'flag implies MOSB'." + ); + println!( + " It does NOT say WHICH group the renderer tests, and reading it as if it did is the \ + mistake decision 0767 made (superseded by 0773). The carved law: 0x40000 is tested \ + inside the portal flood (0x6b42e0 in 0x6b41c0) on the group being VISITED, never on the \ + group the camera stands in — so the predicate is 'any FLOOD-REACHED group carries the \ + bit, and the root names a MOSB'. Stratholme's King's Square is the counter-example that \ + settles it: the camera's own group (39) is EXTERIOR and unflagged, and the reference \ + paints the sky there anyway." + ); + } else { + println!( + "\n=> {flag_only} group(s) set 0x40000 with no MOSB to draw — that would break even the \ + weak 'flag implies MOSB' reading this census rests on; re-derive before building on it." + ); + } + Ok(()) +} + +/// List individual doodad (MDDF) and WMO (MODF) placements around a world position whose model +/// path contains `filter` (case-insensitive) — the per-placement position / Euler rotation / +/// scale / uniqueId ground truth an orientation investigation needs (`doodadscan` only +/// aggregates). +pub fn placescan( + chain: &mut Chain, + map: &str, + center_x: f32, + center_y: f32, + tile_radius: u32, + filter: &str, +) -> Result<()> { + let tiles = benilla_formats::load_tiles_around(chain, map, center_x, center_y, tile_radius) + .with_context(|| format!("loading tiles around ({center_x}, {center_y}) on {map}"))?; + eprintln!("{} tile(s) loaded", tiles.len()); + let needle = filter.to_ascii_lowercase(); + let mut seen: HashSet = HashSet::new(); + let mut hits = 0u32; + for (_, tile) in &tiles { + for d in &tile.doodads { + if seen.insert(d.unique_id) && model_key(&d.model).contains(&needle) { + hits += 1; + println!( + "MDDF uid {:>8} pos ({:>9.2}, {:>9.2}, {:>8.2}) rot deg ({:>7.2}, {:>7.2}, {:>7.2}) scale {:.3} {}", + d.unique_id, + d.position[0], d.position[1], d.position[2], + d.rotation[0], d.rotation[1], d.rotation[2], + d.scale, + d.model, + ); + } + } + for w in &tile.wmos { + if seen.insert(w.unique_id) && w.model.to_ascii_lowercase().contains(&needle) { + hits += 1; + println!( + "MODF uid {:>8} pos ({:>9.2}, {:>9.2}, {:>8.2}) rot deg ({:>7.2}, {:>7.2}, {:>7.2}) {}", + w.unique_id, + w.position[0], w.position[1], w.position[2], + w.rotation[0], w.rotation[1], w.rotation[2], + w.model, + ); + } + } + } + eprintln!("{hits} placement(s) matched '{filter}'"); + Ok(()) +} + +/// 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. +pub fn doodadscan( + chain: &mut Chain, + map: &str, + center_x: f32, + center_y: f32, + tile_radius: u32, +) -> Result<()> { + let tiles = benilla_formats::load_tiles_around(chain, map, center_x, center_y, tile_radius) + .with_context(|| format!("loading tiles around ({center_x}, {center_y}) on {map}"))?; + eprintln!("{} tile(s) loaded", tiles.len()); + + // Direct M2 placements (MDDF) — deduped by uniqueId, which a tile-straddling doodad + // repeats identically across every tile it touches (decision-0021 terrain streamer's own + // dedup key; see `benilla_formats::Doodad::unique_id`). + let mut seen_doodad_ids: HashSet = HashSet::new(); + let mut m2_instances: HashMap = HashMap::new(); + // WMO placements (MODF) — same dedup, by their own uniqueId. + let mut seen_wmo_ids: HashSet = HashSet::new(); + let mut wmo_instances: HashMap = HashMap::new(); + for (_, tile) in &tiles { + for d in &tile.doodads { + if seen_doodad_ids.insert(d.unique_id) { + *m2_instances.entry(model_key(&d.model)).or_insert(0) += 1; + } + } + for w in &tile.wmos { + if seen_wmo_ids.insert(w.unique_id) { + *wmo_instances.entry(w.model.clone()).or_insert(0) += 1; + } + } + } + let direct_m2_instances: u32 = m2_instances.values().sum(); + eprintln!( + "{direct_m2_instances} MDDF doodad placement(s) across {} unique M2 model(s)", + m2_instances.len() + ); + eprintln!( + "{} MODF WMO placement(s) across {} unique WMO model(s)", + seen_wmo_ids.len(), + wmo_instances.len() + ); + + // Fold each unique WMO's doodad-set-**0** M2 props into the same instance table (set 0 is + // the WMO's always-on global set, per `WmoDoodadSet` doc), each multiplied by that WMO's + // own (deduped) placement count — one building placement = one instance of every set-0 prop + // it carries. + let mut wmo_root_failures = 0u32; + for (wmo_path, &count) in &wmo_instances { + let root_path = wmo_path.to_ascii_lowercase(); // matches `load_wmo`'s own normalization + let root = chain + .read_file(&root_path) + .ok() + .and_then(|bytes| benilla_formats::parse_wmo_root(&bytes).ok()); + let Some(root) = root else { + wmo_root_failures += 1; + continue; + }; + let Some(set0) = root.doodad_sets().first() else { + continue; + }; + let range = set0.start as usize..(set0.start as usize + set0.count as usize); + for wd in root.doodads().get(range).unwrap_or(&[]) { + if !wd.model.is_empty() { + *m2_instances.entry(model_key(&wd.model)).or_insert(0) += count; + } + } + } + if wmo_root_failures > 0 { + eprintln!(" ({wmo_root_failures} WMO root(s) failed to read/parse — skipped)"); + } + + // Per-unique-model animation summary — one parse per model regardless of instance count. + let mut summaries: HashMap = HashMap::new(); + let mut parse_failures: Vec<(String, String)> = Vec::new(); + for model in m2_instances.keys() { + match benilla_formats::load_m2_animation_summary(chain, model) { + Ok(s) => { + summaries.insert(model.clone(), s); + } + Err(e) => parse_failures.push((model.clone(), e.to_string())), + } + } + + let total_instances: u32 = m2_instances.values().sum(); + let total_models = m2_instances.len(); + println!(); + println!( + "=== totals ({total_instances} M2 instance(s), {total_models} unique model(s), {} parse failure(s)) ===", + parse_failures.len() + ); + + type ChannelCheck = (&'static str, fn(&M2AnimSummary) -> bool); + let checks: [ChannelCheck; 9] = [ + ("seq-0 bone motion", |s| s.seq0_has_bone_motion), + ("moving seq0, >1 variation", |s| { + s.seq0_has_bone_motion && s.seq0_variation_count > 1 + }), + ("global-seq bones", |s| !s.global_seq_channels.is_empty()), + ("animated transparency", |s| s.transparency_tracks.1 > 0), + ("animated color", |s| { + s.color_rgb_tracks.1 > 0 || s.color_alpha_tracks.1 > 0 + }), + ("texture transforms", |s| s.texture_transform_count > 0), + ("particles", |s| s.particle_emitter_count > 0), + ("emitter on moving bone", |s| { + s.emitter_bones.iter().any(|e| e.chain_animated()) + }), + ("ribbons", |s| s.ribbon_emitter_count > 0), + ]; + let report_row = |label: &str, pred: &dyn Fn(&M2AnimSummary) -> bool| { + let inst: u32 = m2_instances + .iter() + .filter_map(|(m, &c)| summaries.get(m).filter(|s| pred(s)).map(|_| c)) + .sum(); + let models = summaries.values().filter(|s| pred(s)).count(); + println!( + " {label:22} {inst:>6} instances ({:5.1}%) {models:>4} models ({:5.1}%)", + 100.0 * f64::from(inst) / f64::from(total_instances.max(1)), + 100.0 * models as f64 / total_models.max(1) as f64, + ); + }; + for (label, pred) in checks { + report_row(label, &pred); + } + report_row("NO animated channel", &|s| s.is_fully_static()); + + println!(); + println!("=== top 30 models by instance count ==="); + println!( + "{:>6} {:>4} {:>4} {:>5} {:>5} {:>4} {:>4} {:>4} model", + "count", "seq0", "gseq", "trns", "clr", "txfm", "part", "ribn" + ); + let mut ranked: Vec<(&String, &u32)> = m2_instances.iter().collect(); + ranked.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); + for (model, &count) in ranked.iter().take(30) { + match summaries.get(*model) { + Some(s) => println!( + "{count:>6} {:>4} {:>4} {:>5} {:>5} {:>4} {:>4} {:>4} {model}", + yn(s.seq0_has_bone_motion), + yn(!s.global_seq_channels.is_empty()), + yn(s.transparency_tracks.1 > 0), + yn(s.color_rgb_tracks.1 > 0 || s.color_alpha_tracks.1 > 0), + yn(s.texture_transform_count > 0), + yn(s.particle_emitter_count > 0), + yn(s.ribbon_emitter_count > 0), + ), + None => println!("{count:>6} {model}"), + } + } + + // The rare material channels by NAME (each is <1% of instances, so the top-30 table + // almost never surfaces them): the exact models the phase-2/3 material-animation work + // verifies against. + println!(); + println!("=== material-channel models (animated transparency / color / UV) ==="); + let mut rare: Vec<(&String, &u32)> = m2_instances + .iter() + .filter(|(m, _)| { + summaries.get(*m).is_some_and(|s| { + s.transparency_tracks.1 > 0 + || s.color_rgb_tracks.1 > 0 + || s.color_alpha_tracks.1 > 0 + || s.texture_transform_count > 0 + }) + }) + .collect(); + rare.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); + for (model, &count) in &rare { + let s = &summaries[*model]; + println!( + "{count:>6} trns:{} clr:{} txfm:{} {model}", + s.transparency_tracks.1, + s.color_rgb_tracks.1 + s.color_alpha_tracks.1, + s.texture_transform_count, + ); + } + + // Moving-seq0 models with a variation chain, by NAME (the wow-re §4a random-variation + // arm correction): the exact placed models where variationIdx −1 vs 0 is visible at all. + println!(); + println!("=== moving-seq0 multi-variation models ==="); + let mut varied: Vec<(&String, &u32)> = m2_instances + .iter() + .filter(|(m, _)| { + summaries + .get(*m) + .is_some_and(|s| s.seq0_has_bone_motion && s.seq0_variation_count > 1) + }) + .collect(); + varied.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); + for (model, &count) in &varied { + let s = &summaries[*model]; + println!( + "{count:>6} {} variation(s) of seq0 {model}", + s.seq0_variation_count + ); + } + + // Emitters hosted on a moving bone chain, by NAME (0130 phase 4 grounding): the exact + // placed models where emitter bone-follow is visible at all — an emitter on a static + // chain sits at its rest pose whether or not we attach it. + println!(); + println!("=== emitter-on-moving-bone models ==="); + let mut movers: Vec<(&String, &u32)> = m2_instances + .iter() + .filter(|(m, _)| { + summaries + .get(*m) + .is_some_and(|s| s.emitter_bones.iter().any(|e| e.chain_animated())) + }) + .collect(); + movers.sort_by(|a, b| b.1.cmp(a.1).then_with(|| a.0.cmp(b.0))); + for (model, &count) in &movers { + let s = &summaries[*model]; + let moving = s + .emitter_bones + .iter() + .filter(|e| e.chain_animated()) + .count(); + println!( + "{count:>6} {moving}/{} emitter(s) on moving bones {model}", + s.emitter_bones.len() + ); + } + + if !parse_failures.is_empty() { + println!(); + println!("=== parse failures ({}) ===", parse_failures.len()); + for (model, err) in &parse_failures { + println!(" {model}: {err}"); + } + } + + Ok(()) +} diff --git a/crates/benilla-formats/src/liquid.rs b/crates/benilla-formats/src/liquid.rs index c0e08fe4..3e20d25a 100644 --- a/crates/benilla-formats/src/liquid.rs +++ b/crates/benilla-formats/src/liquid.rs @@ -1,29 +1,43 @@ //! MCLQ liquid surfaces → per-chunk water meshes (Phase E; Phase 1 = inland water). //! //! Vanilla liquid is **MCLQ** — per-MCNK, a 9×9 absolute-height grid + an 8×8 cell-flag grid -//! (no WotLK MH2O). `wow-adt`'s [`MclqChunk`] decodes it; we validated its output **byte-identical** -//! to a hand-decode of the raw 804-B payload against `World\Maps\Azeroth\Azeroth_32_48.adt` -//! (wow-adt 0.6.4, 2026-05-31): per-vertex `height`, raw `tile_flags`, `min/max_height`, and the -//! MCNK-flag-derived `liquid_type` all match (the on-disk `QLCM` magic sits at the offset; the -//! payload — and the crate's parse — start 8 bytes in). +//! (no WotLK MH2O), one 804-byte block per set liquid header bit. [`MclqChunk`] decodes it; we +//! validated the output **byte-identical** to a hand-decode of the raw payload against +//! `World\Maps\Azeroth\Azeroth_32_48.adt` (2026-05-31): per-vertex `height`, raw `tile_flags` and +//! `min/max_height` all match (the on-disk `QLCM` magic sits at the offset; the payload — and the +//! crate's parse — start 8 bytes in). //! //! Output is in **raw WoW coords** (+X north, +Y west, +Z up, yards), like [`crate::terrain`]; the //! renderer applies the WoW→Bevy transform and ports the `ocean0_s.bls` shader. We emit a flat //! surface (normal `(0,0,1)`) of 2 triangles per **wet** cell; dry cells (flag low-nibble `0xf`, -//! whose verts carry the FLT_MAX sentinel height) are skipped. Magma/slime is deferred to Phase 3. +//! whose verts carry the FLT_MAX sentinel height) are skipped. +//! +//! **The cell nibble is the type** (VERIFIED `0x6ba970`/`0x68d9b0`) — the MCNK header bits only say +//! how many blocks a chunk carries, and their bit→type ordering was never byte-proven. Reading the +//! type off the header instead is what kept every open-world lava surface in the game from +//! rendering (Burning Steppes, Searing Gorge, Un'Goro) and what put the ocean texture on 28 coastal +//! river blocks while dropping the sea beside them. `build_liquid_mesh` runs the cells first for +//! exactly that reason. -use benilla_adt::{LiquidType, MclqChunk}; +use benilla_adt::MclqChunk; use crate::terrain::{snap_to_lattice, UNIT_SIZE}; /// Which animated texture set + render path a liquid surface uses (selects the frame set in -/// `benilla`). The MCLQ (ADT) path emits only Still/Rapids/Ocean; the WMO MLIQ path additionally -/// emits Magma/Slime (canals, fountains, the Ironforge lava, dungeon pools). +/// `benilla`). The MCLQ (ADT) path emits Still/Ocean/Magma — the reference's three fixed ADT liquid +/// queues; the WMO MLIQ path additionally emits Slime and Rapids (canals, fountains, the Ironforge +/// lava, Undercity). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum LiquidKind { /// Still water — `XTextures\river\lake_a.*` (30 frames). Lakes, ponds, slow rivers, WMO canals. Still, - /// Rapids — `XTextures\river\fast_a.*` (16 frames). Fast streams (per-cell nibble `8`). + /// Rapids — `XTextures\river\fast_a.*` (16 frames). Per-cell nibble `8`. + /// + /// **Nothing in the shipped 1.12.1 data selects it**: a sweep of every MCLQ cell on all 12 + /// terrain maps and every one of the 302 MLIQ-bearing WMO groups finds nibbles `{1, 4, 6}` and + /// `{0, 2, 3, 4, 6, 7}` respectively — never `8`. Kept because the name table `0x86a000` really + /// does map slot 8 here and the WMO path binds the raw nibble, so authored data *could*; the ADT + /// path can't reach it either way (its river queue hard-codes texcache type 4). Rapids, /// Ocean — `XTextures\ocean\ocean_h.*` (30 frames). Coastal / sea tiles. Ocean, @@ -32,12 +46,20 @@ pub enum LiquidKind { /// wow-re `liquid-render-state-sided.md` §3/§5, which corrected the earlier /// `rf-water-liquid-type-texture-material.md` reading of the `0x68d890` vert-fill: its /// `0x3f800000` is an up normal's Z, not a colour or alpha, and it is byte-identical in the river - /// and ocean fills). WMO liquid only (nibbles `2`/`6`) — the Ironforge Great Forge. + /// and ocean fills). + /// + /// Reached from **both** paths: WMO MLIQ (the Ironforge Great Forge) and — since the ADT magma + /// queue landed — MCLQ, which is all the open-world lava there is: 124 chunks in Burning Steppes + /// and Searing Gorge, 4 in Un'Goro. Its UVs are authored per-vertex, not generated + /// ([`benilla_adt::LiquidVertex::texcoords`]). Magma, /// Slime — `XTextures\slime\slime.*`. Its own texture on the magma render category, and **code-path /// identical to magma** on the WMO side: one handler, no type-2-vs-3 branch anywhere (VERIFIED - /// wow-re `liquid-render-state-sided.md` §5). WMO liquid only (nibbles `3`/`7`) — Undercity, the - /// Sludge Fields. + /// wow-re `liquid-render-state-sided.md` §5). + /// + /// **WMO liquid only** (nibbles `3`/`7`) — Undercity, the Sludge Fields. Not a deferral: the + /// reference has no ADT queue for slime at all (VERIFIED — `0x68de40` dispatches only its three + /// queues, types 4/1/6), and no shipped MCLQ cell carries nibble 3 or 7 to feed one. Slime, } @@ -57,6 +79,27 @@ impl LiquidKind { } } + /// The kind an **ADT** (MCLQ) cell nibble selects — which is *not* [`Self::from_nibble`]. + /// + /// The reference's ADT liquid render is **three fixed queues, each hard-coding its texcache + /// type**: `0x6851b0` → type 4 `lake_a` (`mov ecx,4` @`0x68db9d`), `0x685010` → type 1 `ocean_h` + /// (@`0x68dabb`), `0x6855a0` → type 6 `lava` (@`0x68dcab`). The cell nibble selects queue + /// *membership* — via the class `nibble & 3` — not the texture, so an ADT nibble 0 and 8 both + /// draw `lake_a`, and 2 and 6 both draw `lava`. **Slime has no ADT queue at all**: it renders + /// only on the WMO path, so class 3 is `None` here (VERIFIED wow-re + /// `rf-water-liquid-type-texture-material.md` + `terrain.md`). + /// + /// The WMO path is the type-faithful one — it binds `0x68aac0(raw nibble)` — and keeps + /// [`Self::from_nibble`]. + pub fn from_adt_nibble(nibble: u8) -> Option { + match nibble & 3 { + 0 => Some(LiquidKind::Still), + 1 => Some(LiquidKind::Ocean), + 2 => Some(LiquidKind::Magma), + _ => None, // class 3 — slime, which the reference never dispatches from an ADT + } + } + /// Whether this kind renders as an **opaque, unlit** surface (the animated texture is the body /// colour, no water swatch / depth ramp / N·L darkening) — magma and slime. Water/ocean use the /// depth-swatch `ocean0_s.bls` path instead. @@ -95,14 +138,25 @@ pub struct LiquidMesh { pub wet: Vec, /// Vertex positions `[x, y, z]` in WoW yards — `cols·rows` entries, row-major. pub positions: Vec<[f32; 3]>, - /// Cell-index UVs (`¼` per cell, tiling) parallel to `positions`. No scroll for water. + /// Texture UVs parallel to `positions`. No scroll for water. + /// + /// Generated cell-index UVs (`¼` per cell, tiling) for **water and ocean** — and for WMO liquid, + /// anchored to model space over the same period. **Magma is different**: its UVs are *authored* + /// per vertex and read straight off the MCLQ union bytes (`u = s · 3/256`), because the reference's + /// lava batch is single-stage with a reset texture matrix, so `tc0` is the vertex's own coord + /// rather than anything the client generates ([`benilla_adt::LiquidVertex::texcoords`]). pub uvs: Vec<[f32; 2]>, /// Per-vertex liquid swatch coord **V** `0..1` from the MCLQ `SVert` depth byte, parallel to /// `positions`. River/lake = `clamp(byte/42)` (VERIFIED `c81768`, saturates ~5 yd); ocean = byte/255 /// (placeholder — ocean uses a different non-LUT path, see `build_liquid_mesh`). The reference uses /// this single V to index the depth swatch for BOTH the body-colour band (shallow→deep water rows) - /// and the opacity ramp (`colorTex.a` = `127+2·row`, deeper = more opaque). Magma/slime reuse - /// the union bytes for UVs (N/A here). + /// and the opacity ramp (`colorTex.a` = `127+2·row`, deeper = more opaque). + /// + /// **Zero on ADT magma, and read by nothing**: those union bytes are texture coords, not a depth + /// byte, and the reference's magma vert-fill reads no ramp at all (`0x68d890` — no LUT load, + /// unlike the river/ocean fills). The shader's fullbright branch returns before it ever samples + /// this, so the zero is a statement, not a value. (The WMO builder fills its own flat + /// `WMO_WATER_DEPTH_V` for every kind, magma and slime included — equally unread there.) pub depths: Vec, /// Triangle indices into `positions` — 6 per wet cell, cells in row-major order. Derived from /// [`Self::wet`]; the render form. @@ -124,8 +178,12 @@ const GRID: usize = 9; const CELLS: usize = 8; /// Cell-flag low nibble meaning "dry / do not render". const DRY_NIBBLE: u8 = 0x0f; -/// Cell-flag low nibble meaning "rapids" (`river\fast_a`) rather than still water. -const RAPIDS_NIBBLE: u8 = 0x08; +/// The scale the reference applies to a magma vertex's authored `u16` texture coords: +/// `u = (s as i32 as f64 · TEXC) as f32`, `TEXC = 0x3c400000 = 3/256`. VERIFIED bit-exact at +/// `WoW.exe 0x68d890` (`liquid_render_verts`), emulation-diffed over 60 randomized fills in wow-re +/// `crates/terrain/tests/difftest/fills.rs`. Authored steps run ≈42 units per 4.167-yd cell, so one +/// lava repeat spans ≈8.5 yd — twice the density of the ¼-per-cell water field. +const MAGMA_TEXCOORD_SCALE: f32 = f32::from_bits(0x3c40_0000); /// Verts under no liquid carry FLT_MAX (`0x7F7FFFFF`) — `is_finite()` is TRUE for it, so gate on /// magnitude instead. const HEIGHT_SENTINEL: f32 = 1.0e9; @@ -138,27 +196,77 @@ const HEIGHT_SENTINEL: f32 = 1.0e9; /// the deep/teal swatch row until this. The river depth ramp is ≈8.5 byte/yd, so V saturates at ≈5 yd.) const RIVER_DEPTH_V_SATURATION: f32 = 42.0; -/// Build a flat water surface mesh from a parsed MCLQ chunk at MCNK origin `position` -/// (the chunk header's raw WoW `[x, y, z]`). Returns `None` when the chunk has no wet cells -/// or is magma/slime (Phase 3). +/// Build a flat liquid surface mesh from one parsed MCLQ **block** at MCNK origin `position` (the +/// chunk header's raw WoW `[x, y, z]`). A chunk can hold more than one block — see [`MclqChunk`]. +/// +/// `None` when the block has no wet cell, or when its cells are slime, which the reference has no +/// ADT queue for ([`LiquidKind::from_adt_nibble`]). pub(crate) fn build_liquid_mesh(mclq: &MclqChunk, position: [f32; 3]) -> Option { - // Coarse kind from the MCNK-flag liquid type; magma/slime deferred. - let base_kind = match mclq.liquid_type { - LiquidType::Water => LiquidKind::Still, - LiquidType::Ocean => LiquidKind::Ocean, - LiquidType::Magma | LiquidType::Slime => return None, - }; + if mclq.vertices.len() < GRID * GRID || mclq.tile_flags.len() < CELLS * CELLS { + return None; + } + + // ── Cells first, because the CELLS decide the kind. ── + // 2 tris per wet cell. Skip dry cells (low nibble 0xf) and any cell with a sentinel-height + // corner (belt-and-suspenders). + let mut indices = Vec::with_capacity(CELLS * CELLS * 6); + let mut wet = vec![false; CELLS * CELLS]; + // Wet-cell nibble tally → the block's majority nibble, which is BOTH its render kind and its + // sound class (`LiquidMesh::sound_nibble`). Counted over genuinely-wet cells only, so it + // describes what is actually drawn. + let mut nibble_counts = [0u32; 16]; + for row in 0..CELLS { + for col in 0..CELLS { + let flag = mclq.tile_flags[row * CELLS + col] & 0x0f; + if flag == DRY_NIBBLE { + continue; + } + let tl = (row * GRID + col) as u32; + let tr = tl + 1; + let bl = ((row + 1) * GRID + col) as u32; + let br = bl + 1; + // Guard against a wet cell whose corner is the sentinel/NaN (data anomaly) — check the + // ORIGINAL heights, not the sanitized `positions`. Wet cells normally carry real heights. + if [tl, tr, bl, br].iter().any(|&i| { + let raw = mclq.vertices[i as usize].height; + !raw.is_finite() || raw.abs() >= HEIGHT_SENTINEL + }) { + continue; + } + nibble_counts[flag as usize] += 1; + // The cell is liquid AND its corners are real — the same gate the triangles pass, so + // the grid the queries read and the mesh the renderer draws can never disagree. + wet[row * CELLS + col] = true; + indices.extend_from_slice(&[tl, bl, br, tl, br, tr]); + } + } + if indices.is_empty() { + return None; + } + + // The kind comes from the CELL NIBBLE — the VERIFIED type source (`0x6ba970`/`0x68d9b0`) — not + // from the MCNK header bits, whose bit→type ordering was never byte-proven and which mislabelled + // every two-block river-mouth chunk as ocean. In shipped data a block's wet cells are of one + // class throughout, so the majority is the block; the tally is what breaks a tie. + let sound_nibble = nibble_counts + .iter() + .enumerate() + .max_by_key(|(_, &c)| c) + .map(|(n, _)| n as u8) + .unwrap_or(0); + let kind = LiquidKind::from_adt_nibble(sound_nibble)?; + // Depth-byte → swatch coord `V` divisor (VERIFIED). River/lake (the `c81768`/`FUN_0068d790` path) // saturates at byte 42 = ~5 yd, so the channel middle reaches the deep/teal swatch row. Ocean uses // a DIFFERENT mechanism (`FUN_0068d890` reads explicit per-vertex MCLQ UVs, not a depth LUT) — left // at /255 here pending its own RE + an in-game A/B; an ocean A/B has not been done since this fix. - let depth_v_div = match base_kind { + let depth_v_div = match kind { LiquidKind::Ocean => 255.0, _ => RIVER_DEPTH_V_SATURATION, }; - if mclq.vertices.len() < GRID * GRID || mclq.tile_flags.len() < CELLS * CELLS { - return None; - } + // Magma reads its UVs off the vertex and its depth off nothing at all (see `LiquidMesh::uvs` / + // `::depths`); every other kind generates cell-index UVs and ramps by the depth byte. + let authored_uvs = kind == LiquidKind::Magma; let [wx, wy, _wz] = position; // 81 grid verts in raw WoW coords. Linear index `n`: row = n/9 (steps south, −X), col = n%9 @@ -188,65 +296,26 @@ pub(crate) fn build_liquid_mesh(mclq: &MclqChunk, position: [f32; 3]) -> Option< snap_to_lattice(wy - col * UNIT_SIZE), h, ]); - uvs.push([col * 0.25, row * 0.25]); - // SVert depth byte (water/ocean: union_data[0]) → swatch coord V (0..1). River/lake saturate at - // byte 42 (`clamp(byte/42)`, VERIFIED `c81768`) so the channel middle hits the deep/teal swatch - // row; ocean at /255 (see `depth_v_div`). The SAME V drives both the body-colour depth band and - // the opacity ramp on the shader side (one swatch row → colour + alpha). - depths.push((mclq.vertices[n].depth_byte() as f32 / depth_v_div).clamp(0.0, 1.0)); - } - - // 2 tris per wet cell. Skip dry cells (low nibble 0xf) and any cell with a sentinel-height - // corner (belt-and-suspenders). Track whether any wet cell is rapids → use fast_a for the - // whole chunk (still/rapids virtually never mix in one MCNK; per-cell sets are a refinement). - let mut indices = Vec::with_capacity(CELLS * CELLS * 6); - let mut wet = vec![false; CELLS * CELLS]; - let mut any_rapids = false; - // Wet-cell nibble tally → the surface's majority sound-class nibble (`LiquidMesh::sound_nibble`). - let mut nibble_counts = [0u32; 16]; - for row in 0..CELLS { - for col in 0..CELLS { - let flag = mclq.tile_flags[row * CELLS + col] & 0x0f; - if flag == DRY_NIBBLE { - continue; - } - nibble_counts[flag as usize] += 1; - let tl = (row * GRID + col) as u32; - let tr = tl + 1; - let bl = ((row + 1) * GRID + col) as u32; - let br = bl + 1; - // Guard against a wet cell whose corner is the sentinel/NaN (data anomaly) — check the - // ORIGINAL heights, not the sanitized `positions`. Wet cells normally carry real heights. - if [tl, tr, bl, br].iter().any(|&i| { - let raw = mclq.vertices[i as usize].height; - !raw.is_finite() || raw.abs() >= HEIGHT_SENTINEL - }) { - continue; - } - if flag == RAPIDS_NIBBLE { - any_rapids = true; - } - // The cell is liquid AND its corners are real — the same gate the triangles pass, so - // the grid the queries read and the mesh the renderer draws can never disagree. - wet[row * CELLS + col] = true; - indices.extend_from_slice(&[tl, bl, br, tl, br, tr]); + if authored_uvs { + // `u = (s as i32 as f64 · 3/256) as f32` — VERIFIED bit-exact `0x68d890`. Unsigned: the + // reference widens the u16 to i32, so 0xffff is 65535, never −1. + let [s, t] = mclq.vertices[n].texcoords(); + let scale = f64::from(MAGMA_TEXCOORD_SCALE); + uvs.push([ + ((f64::from(s)) * scale) as f32, + ((f64::from(t)) * scale) as f32, + ]); + depths.push(0.0); + } else { + uvs.push([col * 0.25, row * 0.25]); + // SVert depth byte (water/ocean: union_data[0]) → swatch coord V (0..1). River/lake saturate + // at byte 42 (`clamp(byte/42)`, VERIFIED `c81768`) so the channel middle hits the deep/teal + // swatch row; ocean at /255 (see `depth_v_div`). The SAME V drives both the body-colour depth + // band and the opacity ramp on the shader side (one swatch row → colour + alpha). + depths.push((mclq.vertices[n].depth_byte() as f32 / depth_v_div).clamp(0.0, 1.0)); } } - if indices.is_empty() { - return None; - } - let kind = if any_rapids && base_kind == LiquidKind::Still { - LiquidKind::Rapids - } else { - base_kind - }; - let sound_nibble = nibble_counts - .iter() - .enumerate() - .max_by_key(|(_, &c)| c) - .map(|(n, _)| n as u8) - .unwrap_or(0); Some(LiquidMesh { grid: [GRID as u32, GRID as u32], wet, @@ -271,6 +340,222 @@ mod tests { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") } + /// Every liquid mesh a real tile builds, with the MCNK origin each came from. + fn tile_liquids(chain: &mut crate::Chain, map: &str, tx: u32, ty: u32) -> Vec { + let bytes = chain + .read_file(&format!("World\\Maps\\{map}\\{map}_{tx}_{ty}.adt")) + .expect("read adt"); + let ParsedAdt::Root(root) = + parse_adt(&mut std::io::Cursor::new(&bytes[..])).expect("parse adt"); + root.mcnk_chunks + .iter() + .flat_map(|m| { + m.liquids + .iter() + .filter_map(|lq| build_liquid_mesh(lq, m.header.position)) + }) + .collect() + } + + /// Whether a mesh's **wet** footprint covers a raw WoW `(x, y)`, and at what surface height — + /// the containment the swim/submersion queries do, done here off the grid directly. + fn wet_at(m: &LiquidMesh, x: f32, y: f32) -> Option { + let cols = m.grid[0] as usize; + for (c, _) in m.wet.iter().enumerate().filter(|(_, &w)| w) { + let (row, col) = (c / (cols - 1), c % (cols - 1)); + let corners = [ + row * cols + col, + row * cols + col + 1, + (row + 1) * cols + col, + (row + 1) * cols + col + 1, + ]; + let xs: Vec = corners.iter().map(|&i| m.positions[i][0]).collect(); + let ys: Vec = corners.iter().map(|&i| m.positions[i][1]).collect(); + let (x0, x1) = ( + xs.iter().cloned().fold(f32::MAX, f32::min), + xs.iter().cloned().fold(f32::MIN, f32::max), + ); + let (y0, y1) = ( + ys.iter().cloned().fold(f32::MAX, f32::min), + ys.iter().cloned().fold(f32::MIN, f32::max), + ); + if (x0..=x1).contains(&x) && (y0..=y1).contains(&y) { + return Some(corners.iter().map(|&i| m.positions[i][2]).sum::() / 4.0); + } + } + None + } + + /// **B21 — "there should be lava here", `.go xyz -7845.99 -1065.50 123.60 0`.** + /// + /// Open-world lava is MCLQ magma, and the ADT mesher used to return `None` for it, so every + /// outdoor lava surface in the game was silently absent while Blackrock's (WMO MLIQ) rendered + /// fine — exactly the shape of the report. The pin is Burning Steppes, tile 33_46. + /// + /// This is the whole of it: a sweep of all 12 shipped terrain maps finds MCLQ magma on five + /// tiles only — 124 chunks across Burning Steppes/Searing Gorge and 4 in Un'Goro — and no MCLQ + /// slime anywhere at all. + #[test] + fn b21_burning_steppes_lava_builds_a_magma_surface_at_the_reported_pin() { + let data = data_dir(); + if !data.is_dir() { + eprintln!("skipping: no WoW client at {}", data.display()); + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let meshes = tile_liquids(&mut chain, "Azeroth", 33, 46); + + let magma: Vec<&LiquidMesh> = meshes + .iter() + .filter(|m| m.kind == LiquidKind::Magma) + .collect(); + assert_eq!(magma.len(), 64, "Burning Steppes tile 33_46 magma chunks"); + + // The reported spot itself is wet, with lava — not merely "somewhere on this tile". + let (px, py, pz) = (-7845.99f32, -1065.50, 123.60); + let hit = magma + .iter() + .find_map(|m| wet_at(m, px, py)) + .expect("the reported pin stands on a magma surface"); + assert!( + (hit - pz).abs() < 6.0, + "lava surface {hit} sits at the reported eye height {pz}" + ); + + // Fullbright + opaque, and carrying the lava-flow sound class (nibble 6: class 2 = magma, + // FluidSpeed 4 → LavaFlowLoop) — the whole magma contract, not just a mesh that exists. + assert!(magma.iter().all(|m| m.kind.is_fullbright())); + assert!( + magma.iter().all(|m| m.sound_nibble == 6), + "shipped ADT magma is nibble 6 throughout" + ); + // No ADT slime exists to build, and the reference has no queue for it if it did. + assert!( + !meshes.iter().any(|m| m.kind == LiquidKind::Slime), + "slime never renders from an ADT" + ); + } + + /// Lava's UVs are **authored per vertex**, not generated — and authored *world-continuously*, so + /// a chunk's east edge repeats its neighbour's west edge exactly. That continuity is the check + /// that the `u16 → ·3/256` read (VERIFIED `0x68d890`) is being applied to the right bytes in the + /// right order: get the scale wrong and it still tiles, get the *field* wrong and the seam shows. + #[test] + fn magma_uvs_come_from_the_vertex_and_run_continuous_across_chunks() { + let data = data_dir(); + if !data.is_dir() { + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let magma: Vec = tile_liquids(&mut chain, "Azeroth", 33, 46) + .into_iter() + .filter(|m| m.kind == LiquidKind::Magma) + .collect(); + + // Not the generated ¼-per-cell field: authored steps are ≈42 units ≈ 0.49 UV per cell. + let stepped = magma.iter().any(|m| { + let du = (m.uvs[1][0] - m.uvs[0][0]).abs(); + du > 0.001 && (du - 0.25).abs() > 0.05 + }); + assert!( + stepped, + "magma UVs are authored, not the water cell-index UVs" + ); + + // Two chunks adjacent along −Y: the west one's col-8 vertex column IS the east one's col-0. + let mut shared = 0; + for a in &magma { + for b in &magma { + let (pa, pb) = (a.positions[8], b.positions[0]); + if (pa[0] - pb[0]).abs() < 0.01 && (pa[1] - pb[1]).abs() < 0.01 { + for row in 0..9 { + let (ua, ub) = (a.uvs[row * 9 + 8], b.uvs[row * 9]); + // Sentinel-height (dry) verts carry no authored coord — skip those. + if ua == [0.0, 0.0] || ub == [0.0, 0.0] { + continue; + } + assert!( + (ua[0] - ub[0]).abs() < 1e-4 && (ua[1] - ub[1]).abs() < 1e-4, + "shared lava edge vertex must carry one UV: {ua:?} vs {ub:?}" + ); + shared += 1; + } + } + } + } + assert!(shared > 0, "no shared lava chunk edge found to check"); + } + + /// **The sweep's second find: an MCNK can carry more than one MCLQ block**, one per set liquid + /// header bit (VERIFIED wow-re `adt-format.md`). 28 shipped Azeroth chunks are river mouths that + /// carry two — the stream *and* the sea beneath it — and reading only the first dropped the sea + /// while the old header-bit priority ("ocean wins") labelled the river block ocean. + /// + /// Hillsbrad's tile 33_33 is one. Both blocks must survive, each with its own kind and height. + #[test] + fn a_river_mouth_chunk_builds_both_its_river_and_its_sea() { + let data = data_dir(); + if !data.is_dir() { + return; + } + let mut chain = crate::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("World\\Maps\\Azeroth\\Azeroth_33_33.adt") + .expect("read adt"); + let ParsedAdt::Root(root) = + parse_adt(&mut std::io::Cursor::new(&bytes[..])).expect("parse adt"); + + let two_block: Vec<&benilla_adt::McnkChunk> = root + .mcnk_chunks + .iter() + .filter(|m| m.liquids.len() > 1) + .collect(); + assert!( + !two_block.is_empty(), + "Azeroth_33_33 carries river-mouth chunks with two liquid blocks" + ); + + for mcnk in two_block { + assert_eq!(mcnk.liquids.len(), 2, "two set liquid bits ⇒ two blocks"); + let built: Vec = mcnk + .liquids + .iter() + .filter_map(|lq| build_liquid_mesh(lq, mcnk.header.position)) + .collect(); + assert_eq!(built.len(), 2, "both blocks mesh"); + // Disk order is header-bit order: bit 2 (river) then bit 3 (ocean) — and the KIND comes + // from each block's own cell nibble, so the river no longer reads as ocean. + assert_eq!(built[0].kind, LiquidKind::Still, "block 0 is the stream"); + assert_eq!(built[1].kind, LiquidKind::Ocean, "block 1 is the sea"); + let sea = built[1].positions[built[1].indices[0] as usize][2]; + let river = built[0].positions[built[0].indices[0] as usize][2]; + assert!( + river > sea + 1.0, + "the stream ({river}) sits above sea level ({sea})" + ); + // **The invariant that makes spawning both safe at a river mouth.** + // `liquid::liquid_at` resolves overlapping footprints by taking the LOWEST surface + // (decision 0634/0701 — the volume you are actually in when two are stacked). If a + // river cell and an ocean cell covered the same XY, the sea at z≈0 would win under a + // stream 5 yd above it and the player would read as dry while standing in the water. + // At every real river mouth the two blocks are authored **disjoint** — each cell + // belongs to exactly one — so they never compete for a position. + // + // Swept game-wide: 28 two-block chunks, and the only 4 whose cells overlap are in + // Kalimdor tile 26_54 (a flat −23.47 sheet under a full-chunk sea at 0.0, in the dead + // water off the south-west corner). That patch read the same way *before* multi-block + // parsing — the first block was all there was — so it is a named residual of the + // lowest-wins rule, not a regression. See the decision record. + for (cell, (&r, &o)) in built[0].wet.iter().zip(&built[1].wet).enumerate() { + assert!( + !(r && o), + "cell {cell} is wet in BOTH blocks — the stacked-surface case liquid_at \ + resolves lowest-wins, which would read this stream as dry" + ); + } + } + } + /// Golden test against a real Elwynn tile: Crystal Lake area `Azeroth_32_48.adt` has 42 MCLQ /// water chunks (all river/still, surface ≈ 143.99 yd). Skips when the client isn't present. #[test] @@ -289,7 +574,7 @@ mod tests { let mut meshes = Vec::new(); for mcnk in &root.mcnk_chunks { - if let Some(mclq) = &mcnk.liquid { + for mclq in &mcnk.liquids { if let Some(m) = build_liquid_mesh(mclq, mcnk.header.position) { meshes.push(m); } diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs index bf338130..c1eda678 100644 --- a/crates/benilla-formats/src/models/m2_batches.rs +++ b/crates/benilla-formats/src/models/m2_batches.rs @@ -458,6 +458,13 @@ pub fn parse_m2_render_submeshes( // M2 render flag 0x04 = "Two-sided (no backface culling)" (wowdev.wiki M2 § Render flags). // Unset ⇒ the real client backface-culls this batch (visible from one side only). let two_sided = material.is_some_and(|m| m.flags.bits() & 0x04 != 0); + // **Where this batch's texture coordinates come from** — the two-hop + // `texCoordSet(+0x12) → texture_unit_lookup(0x9c)`, gated exactly as the reference gates it + // at `0x70b8bd` (`<= 2` = a vertex UV channel, higher = a GENERATED environment + // coordinate). Stage 0 only: we bind one texture per batch, and the reference tests the + // stage's own slot. An env batch authors no usable UVs at all, so the renderer must derive + // them — see `RenderSubmesh::env_map`. + let env_map = model.stage_is_env_mapped(batch, 0); // M2 render flag 0x01 = "Unlit" — lighting disabled, so the batch shows at full texture // brightness regardless of sun/ambient: lamp glass, window panes, glow cards. Reuses the same // fullbright `emissive` path as WMO self-illuminated glass. Verified: the lantern/wagon glass @@ -647,6 +654,7 @@ pub fn parse_m2_render_submeshes( && !separable.get(b).copied().unwrap_or(false) }) }); + sub.env_map = env_map; // texture_unit_lookup > 2 → the runtime generates this stage's UVs sub.additive = additive; // M2 blend mode 3/4 → additive (glow cards) sub.no_depth_write = no_depth_write; // M2 render flag 0x10 sub.no_depth_test = no_depth_test; // M2 render flag 0x08 diff --git a/crates/benilla-formats/src/models/mod.rs b/crates/benilla-formats/src/models/mod.rs index 283ed1b1..7a38b784 100644 --- a/crates/benilla-formats/src/models/mod.rs +++ b/crates/benilla-formats/src/models/mod.rs @@ -104,6 +104,7 @@ fn remap_submesh( uv_anim: None, // set by the M2 path from the batch's texture transform rgb_anim: None, // set by the M2 path from the batch's colour RGB track wmo_batch: None, // set by the WMO path from the MOGP batch-section counts + env_map: false, // set by the M2 path from texture_unit_lookup[texCoordSet] > 2 }, globals, ) diff --git a/crates/benilla-formats/src/models/types.rs b/crates/benilla-formats/src/models/types.rs index 96010032..3c935bc3 100644 --- a/crates/benilla-formats/src/models/types.rs +++ b/crates/benilla-formats/src/models/types.rs @@ -381,6 +381,18 @@ pub struct RenderSubmesh { /// The batch's MOBA section for WMO group batches ([`WmoBatchClass`] — TRANS / INT / EXT, the /// per-class lighting law of an interior group). `None` for every M2 batch. pub wmo_batch: Option, + /// This batch's texture coordinates are **GENERATED, not authored** — a sphere-map environment + /// coordinate derived per frame from the view-space reflection vector, not the vertex UVs + /// ([`benilla_m2::M2Model::stage_is_env_mapped`]: `texture_unit_lookup[texCoordSet] > 2`). + /// + /// The renderer must compute `uv = normalize(P − 2(P·N)N).xy · 0.5 + 0.5` in view space + /// instead of reading [`Self::uvs`], because on such a batch **there are no UVs to read**: the + /// artist leaves the whole mesh at a single point (`GnomeSubwayGlass.m2` — all 330 vertices at + /// exactly `(0,0)`) precisely because the runtime supplies them. Drawing it from the vertex + /// data paints the entire surface in one corner texel of a reflection sheet — the Deeprun Tram + /// glass tube's flat yellow (`AKGNOMEREFLECT.BLP` texel 0,0 = `225,221,142`, doubled by its + /// Mod2x blend). `false` for every WMO batch and for the M2 batches that name a real UV channel. + pub env_map: bool, } /// A render batch that is a single flat **ground-plane quad**: four vertices sharing one authored @@ -456,6 +468,7 @@ impl Default for RenderSubmesh { uv_anim: None, rgb_anim: None, wmo_batch: None, + env_map: false, } } } diff --git a/crates/benilla-formats/src/models/wmo/group.rs b/crates/benilla-formats/src/models/wmo/group.rs index 926b502d..319c780b 100644 --- a/crates/benilla-formats/src/models/wmo/group.rs +++ b/crates/benilla-formats/src/models/wmo/group.rs @@ -160,6 +160,10 @@ pub fn wmo_group_footprint_tris(group_bytes: &[u8]) -> Option { /// Find a sub-chunk of a group file's **MOGP** super-chunk by on-disk (reversed) magic. The MOGP /// payload is the 0x44-byte group header followed by ordinary `[magic][size][data]` sub-chunks /// (MOPY/MOVT/…/MODR/MOLR); this walks them. +/// +/// Clamps the last sub-chunk to the payload end for the same reason [`find_wmo_chunk`] does — and it +/// is the same file: once MOGP itself is clamped to EOF, the sub-chunk that ran to the old declared +/// end is now the short one, so a break here would just move `Undercity_144`'s loss one level down. fn find_mogp_subchunk<'a>(group_bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a [u8]> { let mogp = find_wmo_chunk(group_bytes, b"PGOM")?; let mut off = 0x44usize; // past the fixed group header @@ -167,10 +171,7 @@ fn find_mogp_subchunk<'a>(group_bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a let size = u32::from_le_bytes([mogp[off + 4], mogp[off + 5], mogp[off + 6], mogp[off + 7]]) as usize; let data_start = off + 8; - let data_end = data_start.checked_add(size)?; - if data_end > mogp.len() { - break; - } + let data_end = data_start.saturating_add(size).min(mogp.len()); if &mogp[off..off + 4] == magic { return Some(&mogp[data_start..data_end]); } @@ -494,22 +495,38 @@ pub fn wmo_group_raw_colors(group_bytes: &[u8]) -> Option> { let Ok(ParsedWmo::Group(group)) = parse_wmo(&mut Cursor::new(group_bytes)) else { return None; }; - (group.vertex_colors.len() == group.vertex_positions.len()).then(|| { - group - .vertex_colors - .iter() - .map(|c| [c.b, c.g, c.r, c.a]) - .collect() - }) + let colors = parallel_colors(&group)?; + Some(colors.iter().map(|c| [c.b, c.g, c.r, c.a]).collect()) +} + +/// The group's MOCV as a buffer **parallel to its positions**, or `None` if it has no usable bake. +/// +/// The length test cannot be plain equality. A WMO's last chunk is allowed to run past EOF and clamp +/// (decision 0972), and when the clamped chunk is MOCV the buffer comes back a whole *record* short: +/// `Undercity_144.wmo` holds 1159 of its declared 1160 colour bytes, so `chunks_exact(4)` yields 289 +/// colours for 290 vertices. Demanding equality threw away **289 good colours over one missing byte** +/// and fell the whole group back to untinted white — a corridor lit `tex × 1.0` inside a city whose +/// every other interior surface is multiplied by a dark bake (decision 0977). +/// +/// So a shortfall inside one record's worth is padded from the last complete colour; anything larger +/// is a genuinely broken bake and still reads `None`, because padding hundreds of vertices from one +/// sample would invent lighting rather than recover it. +fn parallel_colors(group: &WmoGroup) -> Option> { + let (have, want) = (group.vertex_colors.len(), group.vertex_positions.len()); + if have == want { + return Some(group.vertex_colors.clone()); + } + // `want - have == 1` is the clamped-tail case and the only shortfall we reconstruct. + let last = (have + 1 == want).then(|| group.vertex_colors.last().copied())??; + let mut colors = group.vertex_colors.clone(); + colors.push(last); + Some(colors) } /// Shared body of [`wmo_group_fixed_colors`] and the submesh build: clone the authored MOCV and run /// the fade over it. `None` when the group carries no MOCV parallel to its positions. fn fixed_colors(group: &WmoGroup, group_bytes: &[u8], root: &WmoRoot) -> Option> { - if group.vertex_colors.len() != group.vertex_positions.len() { - return None; - } - let mut colors = group.vertex_colors.clone(); + let mut colors = parallel_colors(group)?; let refs = wmo_group_header(group_bytes).map_or((0, 0), |h| (h.portal_ref_start, h.portal_ref_count)); fix_color_vertex_alpha(&mut colors, group, root, refs); diff --git a/crates/benilla-formats/src/models/wmo/mod.rs b/crates/benilla-formats/src/models/wmo/mod.rs index 3d4c8e07..6def47ca 100644 --- a/crates/benilla-formats/src/models/wmo/mod.rs +++ b/crates/benilla-formats/src/models/wmo/mod.rs @@ -36,6 +36,13 @@ pub fn load_wmo(chain: &mut Chain, raw_path: &str) -> Result(bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a [u8]> { let mut off = 0usize; while off + 8 <= bytes.len() { @@ -46,10 +53,10 @@ pub(crate) fn find_wmo_chunk<'a>(bytes: &'a [u8], magic: &[u8; 4]) -> Option<&'a bytes[off + 7], ]) as usize; let data_start = off + 8; - let data_end = data_start.checked_add(size)?; - if data_end > bytes.len() { - break; // truncated chunk — give up - } + // Saturating, then clamped: a garbage size can overflow, and an over-declared one is real + // data the reference tolerates. Either way `data_end >= data_start`, so the slice is valid + // and `off` still advances by at least 8 — the walk terminates. + let data_end = data_start.saturating_add(size).min(bytes.len()); if &bytes[off..off + 4] == magic { return Some(&bytes[data_start..data_end]); } diff --git a/crates/benilla-formats/src/particles.rs b/crates/benilla-formats/src/particles.rs index 80ce6d77..59b36adf 100644 --- a/crates/benilla-formats/src/particles.rs +++ b/crates/benilla-formats/src/particles.rs @@ -324,6 +324,10 @@ pub struct ParticleEmitterDef { pub bone: u16, pub shape: ParticleShape, pub blend: ParticleBlend, + /// Does the scene's light multiply this emitter's quads? See [`lit_of`] for the byte law — it + /// needs the RAW blend field, which [`ParticleBlend`] collapses (5/6 fold to `Alpha`), so the + /// verdict is baked here at parse rather than re-derived downstream. + pub lit: bool, /// **Geometry model** (file+0x18 count / +0x1c offset, an `M2Array` path — wow-re /// `part-model-particles.md`): when it names a resolvable `.m2`, this emitter spawns tiny /// 3-D MODEL instances instead of billboard quads (Whirlwind's blades, Cone of Cold's @@ -611,6 +615,36 @@ fn blend_of(v: u8) -> ParticleBlend { } } +/// `DAT_00811fa8` — the reference's per-blend-mode lighting table, indexed by the emitter's RAW +/// blend field (file+0x28): the multiply/modulate modes light nothing, every other mode does +/// (wow-re `part-scene-multipliers.md` §1, VERIFIED at `0x70bb0a`). +const LIGHTING_BY_BLEND: [bool; 7] = [true, true, true, true, true, false, false]; + +/// Does the reference draw this emitter's quads **LIT** by the scene? +/// +/// A particle emitter has no material of its own: the reference *synthesizes* an `M2Material` from +/// the file record every draw (`0x70d8b0`) and runs it through the SAME batch state producer as an +/// ordinary mesh submesh, so `GL_LIGHTING` (EGxRs id `0x0e`) is decided exactly as it is for a +/// mesh. Byte-verified at `0x70baf0` @`0x70bb00` (wow-re `part-scene-multipliers.md` §1): +/// +/// ```text +/// lit ⇔ (file+0x04 bit 0x1 CLEAR) AND DAT_00811fa8[file+0x28] ≠ 0 +/// ``` +/// +/// **File bit `0x1` is the UNLIT flag** — the *inverse* of the wowdev-wiki lore ("0x1 = affected by +/// lighting"), and matching the M2 render-flag `0x01 = unlit` convention everywhere else in the +/// format. That polarity is the whole point: the fire/spell corpus authors `0x1` and stays bright at +/// night, while the ambient environment sheets that CLEAR it — waterfall spray, chimney smoke, +/// blown dust and snow — are shaded by the world's own light like the geometry they sit against. +/// Rendering those unlit puts a full-white foam sheet in a shaded jungle (the Zul'Gurub waterfall). +fn lit_of(flags: u32, blend_byte: u8) -> bool { + flags & 0x1 == 0 + && LIGHTING_BY_BLEND + .get(usize::from(blend_byte)) + .copied() + .unwrap_or(true) +} + fn shape_of(v: u16) -> ParticleShape { match v { 2 => ParticleShape::Sphere, @@ -773,6 +807,7 @@ pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result Result std::path::PathBuf { std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") diff --git a/crates/benilla-formats/src/terrain.rs b/crates/benilla-formats/src/terrain.rs index 7845b919..4a25cd54 100644 --- a/crates/benilla-formats/src/terrain.rs +++ b/crates/benilla-formats/src/terrain.rs @@ -100,9 +100,10 @@ pub struct ChunkMesh { /// `AreaTable.dbc` id of this chunk (MCNK +0x34) — the zone/subzone under the player's feet; /// drives zone music/ambience/reverb selection (decision 0070). pub area_id: u32, - /// The chunk's MCLQ liquid surface (lake/river/ocean), if any — a flat water mesh built - /// alongside the terrain (see [`crate::liquid`]). `None` for dry chunks and magma/slime (Phase 3). - pub liquid: Option, + /// The chunk's MCLQ liquid surfaces — flat meshes built alongside the terrain (see + /// [`crate::liquid`]). Empty on a dry chunk; **more than one** where the MCNK carries more than + /// one liquid block, as the 28 shipped river-mouth chunks do (a stream over the sea). + pub liquids: Vec, } impl ChunkMesh { @@ -479,12 +480,13 @@ pub fn adt_to_tile_mesh(adt_bytes: &[u8]) -> Result { no_effect_doodad[k] = (ned8[k / 8] >> (k % 8)) & 0x1 == 1; } - // MCLQ liquid surface (lake/river/ocean), built on the same lattice as this chunk's - // terrain. `None` for dry chunks and (for now) magma/slime. See `crate::liquid`. - let liquid = mcnk - .liquid - .as_ref() - .and_then(|mclq| crate::liquid::build_liquid_mesh(mclq, h.position)); + // MCLQ liquid surfaces (lake/river/ocean/lava), built on the same lattice as this chunk's + // terrain — one mesh per liquid block the MCNK carries. See `crate::liquid`. + let liquids: Vec<_> = mcnk + .liquids + .iter() + .filter_map(|mclq| crate::liquid::build_liquid_mesh(mclq, h.position)) + .collect(); chunks.push(ChunkMesh { positions, @@ -502,7 +504,7 @@ pub fn adt_to_tile_mesh(adt_bytes: &[u8]) -> Result { index_x: h.index_x, index_y: h.index_y, area_id: h.area_id, - liquid, + liquids, }); } @@ -728,7 +730,7 @@ mod tests { index_x: 0, index_y: 0, area_id: 0, - liquid: None, + liquids: Vec::new(), } } diff --git a/crates/benilla-formats/tests/m2_billboard_card_face.rs b/crates/benilla-formats/tests/m2_billboard_card_face.rs index 0e7e21df..9215a454 100644 --- a/crates/benilla-formats/tests/m2_billboard_card_face.rs +++ b/crates/benilla-formats/tests/m2_billboard_card_face.rs @@ -135,6 +135,7 @@ fn the_shape_ignores_edge_on_correct_and_degenerate_batches() { uv_anim: None, rgb_anim: None, wmo_batch: None, + env_map: false, }; assert!( diff --git a/crates/benilla-formats/tests/m2_env_map.rs b/crates/benilla-formats/tests/m2_env_map.rs new file mode 100644 index 00000000..1cb4dbc1 --- /dev/null +++ b/crates/benilla-formats/tests/m2_env_map.rs @@ -0,0 +1,84 @@ +//! Difftest the M2 **generated-texcoord** (environment-map) detection against real art — the +//! `texCoordSet(+0x12) → texture_unit_lookup(0x9c)` two-hop the reference gates at `0x70b8bd` +//! (`<= 2` = a vertex UV channel, higher = a generated env coordinate). +//! +//! Both halves matter, and only real art proves them. The Deeprun Tram's glass tube is the +//! **positive**: it authors `texture_unit_lookup = [-1]` and, precisely because the runtime is +//! meant to supply the coordinates, leaves **every one of its 330 vertices at exactly (0,0)** — so +//! a renderer that misses the flag paints the whole tube in one corner texel of a reflection sheet +//! (`AKGNOMEREFLECT.BLP` texel 0,0 = 225,221,142, doubled by the batch's Mod2x blend: the flat +//! yellow). The weapon rack is the **discriminator**: the same model carries both kinds, so a +//! parse that simply answered "env" everywhere would pass the first assert and fail here. +//! +//! Skips (passes) when the client isn't present at `/WoW/Data`. + +use std::path::PathBuf; + +use benilla_formats::{load_m2_mesh, open_chain}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +#[test] +fn tram_glass_batches_generate_their_texcoords() { + 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 subs = load_m2_mesh( + &mut chain, + "World\\Generic\\Gnome\\Passive Doodads\\GnomeMachine\\GnomeSubwayGlass.m2", + ) + .expect("load GnomeSubwayGlass"); + + assert!(!subs.is_empty(), "the glass tube has render batches"); + assert!( + subs.iter().all(|s| s.env_map), + "every GnomeSubwayGlass batch authors texture_unit_lookup = -1 (generated texcoords)" + ); + // The half that makes the flag load-bearing rather than cosmetic: there is no fallback here, + // because the authored UVs carry no information at all. + for s in &subs { + assert!( + s.uvs.iter().all(|uv| uv[0] == 0.0 && uv[1] == 0.0), + "an env-mapped batch's authored UVs are degenerate — the runtime supplies them" + ); + } +} + +#[test] +fn weapon_rack_splits_env_from_uv_batches() { + 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 subs = load_m2_mesh( + &mut chain, + "World\\Generic\\Human\\Passive Doodads\\WeaponRacks\\GeneralWeaponrack01.m2", + ) + .expect("load GeneralWeaponrack01"); + + // `texture_unit_lookup = [0, -1]`: the rack/blade batches name UV channel 0, the two + // ARMORREFLECT sheen layers (`texCoordSet 1`) generate theirs. + let env: Vec<&str> = subs + .iter() + .filter(|s| s.env_map) + .filter_map(|s| s.texture.as_deref()) + .collect(); + assert!( + !env.is_empty() + && env + .iter() + .all(|t| t.to_ascii_lowercase().contains("armorreflect")), + "only the ARMORREFLECT sheen layers env-map on the rack, got {env:?}" + ); + assert!( + subs.iter().any(|s| !s.env_map), + "the rack's body and blade batches read their authored UVs" + ); +} diff --git a/crates/benilla-formats/tests/wmo_chunk_overrun.rs b/crates/benilla-formats/tests/wmo_chunk_overrun.rs new file mode 100644 index 00000000..d9d59c19 --- /dev/null +++ b/crates/benilla-formats/tests/wmo_chunk_overrun.rs @@ -0,0 +1,108 @@ +//! **The last chunk clamps to EOF.** A WMO's chunk stream is not guaranteed to tile its file +//! exactly, and the reference tolerates the slop: its walk "reads chunks while the 8-byte header is +//! in-bounds and clamps the last chunk to EOF (never requires exact tiling)" — wow-re `models.md`, +//! "WMO chunk-structure contract" (VERIFIED, with a 6332-file corpus oracle), whose own worked +//! example is the single file in the game that needs the rule: `Undercity_144.wmo`'s MOGP declares +//! one byte more than the file holds. +//! +//! We used to `break` there and hand the caller nothing. That cost g144 its whole MOGP — flags, +//! portal-ref span, area id, fog, doodad and light refs — and since the portal flood reaches a +//! neighbour only through a group's ref span, the group became a **dead end**: g144 is the short +//! corridor joining g95 and g152, so the rooms past it culled from either side and you saw sky +//! through the doorway (B26, decision 0972). +//! +//! Two oracles, because the bug had two halves. The corpus sweep is the one that generalises: it is +//! the same shape as the reference's own "0 rejected" oracle, and it fails for a *new* file as well +//! as for this one. The byte pins on g144 catch a clamp that silently returns the wrong slice. +//! Skips when the client isn't present. + +use std::path::PathBuf; + +use benilla_formats::{parse_wmo_root, wmo_group_header, Chain}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +/// The file the rule exists for. Its MOGP header must come back whole, with the exact values the +/// raw bytes carry at MOGP+0x08 (flags) and MOGP+0x24/0x26 (the portal-ref span) — the two fields +/// whose loss dead-ended the flood. +#[test] +fn undercity_144_mogp_survives_its_one_byte_overrun() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let reader = Chain::open(&data).expect("open vanilla patch chain"); + let bytes = reader + .read("World\\wmo\\Lorderon\\Undercity\\Undercity_144.wmo") + .expect("read Undercity_144"); + + // The overrun is a property of the shipped file — if this ever stops holding, the test below is + // no longer exercising the clamp and the rest of this file is decoration. + let declared = + u32::from_le_bytes([bytes[0x10], bytes[0x11], bytes[0x12], bytes[0x13]]) as usize; + assert_eq!( + 0x0c + 8 + declared, + bytes.len() + 1, + "Undercity_144's MOGP is supposed to declare exactly one byte past EOF" + ); + + let h = wmo_group_header(&bytes).expect("MOGP must survive the clamp"); + assert_eq!(h.flags, 0xa805, "MOGP+0x08 flags"); + assert_eq!(h.portal_ref_start, 397, "MOGP+0x24 portal-ref start"); + assert_eq!(h.portal_ref_count, 2, "MOGP+0x26 portal-ref count"); +} + +/// The general oracle, mirroring the reference's own: **every** WMO in the corpus yields the chunk +/// its loader needs — a root parses, a group gives up its MOGP header. This is what makes the rule a +/// contract rather than a one-file patch; `wmo_chunk_census --all` is the same sweep with a readout. +#[test] +fn every_wmo_in_the_corpus_yields_its_loader_chunk() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut reader = Chain::open(&data).expect("open vanilla patch chain"); + let mut paths: Vec = reader + .list() + .expect("list the patch chain") + .into_iter() + .map(|e| e.name) + .filter(|n| n.to_ascii_lowercase().ends_with(".wmo")) + .collect(); + paths.sort(); + paths.dedup(); + assert!( + paths.len() > 5000, + "expected the full WMO corpus, found {} files", + paths.len() + ); + + let mut checked = 0u32; + let mut bad: Vec = Vec::new(); + for p in &paths { + let Ok(b) = reader.read_file(p) else { continue }; + if b.is_empty() { + continue; // 0-byte stubs ship in the corpus; the reference accepts them too + } + checked += 1; + // A group file is MVER + MOGP, so its 4CC sits at 0x0c; anything else is a root. + let ok = if b.len() > 16 && &b[12..16] == b"PGOM" { + wmo_group_header(&b).is_some() + } else { + parse_wmo_root(&b).is_ok() + }; + if !ok { + bad.push(p.clone()); + } + } + assert!( + bad.is_empty(), + "{} of {checked} WMO files lost the chunk their loader needs: {:?}", + bad.len(), + &bad[..bad.len().min(10)] + ); +} diff --git a/crates/benilla-formats/tests/wmo_mocv_short_tail.rs b/crates/benilla-formats/tests/wmo_mocv_short_tail.rs new file mode 100644 index 00000000..84ab68e8 --- /dev/null +++ b/crates/benilla-formats/tests/wmo_mocv_short_tail.rs @@ -0,0 +1,154 @@ +//! **A MOCV one record short is still a bake.** The companion to `wmo_chunk_overrun`: clamping the +//! last chunk to EOF (decision 0972) is only half the tolerance, because the chunk that clamps in +//! `Undercity_144.wmo` *is* MOCV — 1159 of its declared 1160 bytes, so the colour buffer parses to +//! 289 entries for 290 vertices. +//! +//! The reader then demanded MOCV be exactly parallel to the positions and returned `None` otherwise, +//! which threw away 289 good colours over one missing byte. An interior batch draws `tex × MOCV`, and +//! absent colour renders as white, so that corridor lit at full brightness and untinted inside a city +//! whose every other interior surface is multiplied by a dark bake — the pale, cold arch in the +//! director's shot, against a reference that shows it warm and lantern-lit (decision 0977). +//! +//! Skips when the client isn't present. + +use std::path::PathBuf; + +use benilla_formats::{parse_wmo_root, wmo_group_raw_colors, Chain}; + +fn vanilla_data_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data") +} + +/// Walk a group file's MOGP sub-chunks (payload clamped to EOF, as the loader does) and return the +/// declared byte length of `tag`, plus how many of those bytes the file actually holds. +fn subchunk(group: &[u8], tag: &[u8; 4]) -> Option<(usize, usize)> { + let mogp_size = + u32::from_le_bytes([group[0x10], group[0x11], group[0x12], group[0x13]]) as usize; + let payload = group.get(0x14..(0x14 + mogp_size).min(group.len()))?; + let mut off = 0x44usize; + while off + 8 <= payload.len() { + let size = u32::from_le_bytes([ + payload[off + 4], + payload[off + 5], + payload[off + 6], + payload[off + 7], + ]) as usize; + let start = off + 8; + let end = start.saturating_add(size); + if &payload[off..off + 4] == tag { + return Some((size, end.min(payload.len()).saturating_sub(start))); + } + off = end.min(payload.len()); + if off == payload.len() { + break; + } + } + None +} + +/// The file the tolerance exists for: MOCV is its clamped tail, and the bake it carries is the warm +/// orange the reference renders — not the white an absent bake falls back to. +#[test] +fn undercity_144_keeps_its_bake_despite_a_short_mocv() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let reader = Chain::open(&data).expect("open vanilla patch chain"); + let g = reader + .read("World\\wmo\\Lorderon\\Undercity\\Undercity_144.wmo") + .expect("read Undercity_144"); + + // The data fact this rests on: MOVT holds 290 vertices; MOCV declares 290 colours and the file + // is one byte short of them. If the shipped file ever changes, this stops testing the tail. + let (movt_declared, _) = subchunk(&g, b"TVOM").expect("MOVT"); + let (mocv_declared, mocv_present) = subchunk(&g, b"VCOM").expect("MOCV"); + assert_eq!(movt_declared / 12, 290, "MOVT vertex count"); + assert_eq!( + mocv_declared / 4, + 290, + "MOCV declares one colour per vertex" + ); + assert_eq!( + mocv_present, + mocv_declared - 1, + "MOCV is exactly one byte short" + ); + + let raw = wmo_group_raw_colors(&g).expect("a one-record-short MOCV is still a bake"); + assert_eq!( + raw.len(), + 290, + "the buffer must come back parallel to the vertices" + ); + // BGRA on the wire. The authored bake here is warm orange — the thing that was being replaced by + // white, and the whole visible symptom. + let [b, gr, r, _a] = raw[0]; + assert!( + r > 200 && gr > 100 && gr < 200 && b < 120, + "g144's bake should be warm orange, got rgb({r},{gr},{b})" + ); +} + +/// The tolerance must stay **narrow**. Exactly one group in the corpus has a MOCV that is not a whole +/// number of records parallel to its positions; if that count grows, either the reader regressed or +/// the padding is masking a real parse bug, and either way padding hundreds of vertices from one +/// sample would invent lighting rather than recover it. +#[test] +fn only_one_group_in_the_corpus_has_a_short_mocv() { + let data = vanilla_data_dir(); + if !data.is_dir() { + eprintln!("skipping: vanilla client not present at {}", data.display()); + return; + } + let mut reader = Chain::open(&data).expect("open vanilla patch chain"); + let mut roots: Vec = reader + .list() + .expect("list the patch chain") + .into_iter() + .map(|e| e.name) + .filter(|n| { + let l = n.to_ascii_lowercase(); + l.ends_with(".wmo") + && !l + .trim_end_matches(".wmo") + .ends_with(|c: char| c.is_ascii_digit()) + }) + .collect(); + roots.sort(); + roots.dedup(); + + let mut short: Vec = Vec::new(); + for rp in &roots { + let Ok(rb) = reader.read_file(rp) else { + continue; + }; + let Ok(root) = parse_wmo_root(&rb) else { + continue; + }; + let stem = rp.strip_suffix(".wmo").unwrap_or(rp).to_string(); + for gi in 0..root.group_count() { + let path = format!("{stem}_{gi:03}.wmo"); + let Ok(g) = reader.read_file(&path) else { + continue; + }; + if g.len() < 0x14 { + continue; + } + let (Some((movt, _)), Some((mocv_declared, mocv_present))) = + (subchunk(&g, b"TVOM"), subchunk(&g, b"VCOM")) + else { + continue; // no MOCV at all is normal (every exterior group) + }; + if mocv_present < mocv_declared || mocv_declared / 4 != movt / 12 { + short.push(path); + } + } + } + assert_eq!( + short, + vec!["World\\wmo\\Lorderon\\Undercity\\Undercity_144.wmo".to_string()], + "the short-MOCV tolerance is meant to cover exactly one shipped file" + ); +} diff --git a/crates/benilla-m2/src/lib.rs b/crates/benilla-m2/src/lib.rs index 7ae2da5f..48ad7399 100644 --- a/crates/benilla-m2/src/lib.rs +++ b/crates/benilla-m2/src/lib.rs @@ -131,7 +131,9 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result { p += 8; let texture_lookup_table = arr(p)?; p += 8; - // header 0x9c: texture_unit_lookup (the env-vs-UV mapping table) — skipped for now. + // header 0x9c: texture_unit_lookup — the **env-vs-UV mapping table**. `texUnit.texture_coord_combo_index` + // (+0x12) indexes it; a value `< 3` names a UV channel, `>= 3` (real art: `0xffff`) is a + // *generated* environment coordinate (wow-re `m2-texanim-uv` §`0x70b8bd`, models.md §944). // // The three lookup slots here follow wow-re's *stride-pin reconciliation* (renderFlags pinned // at 0x84 ⇒ texLookup 0x94 · texUnitLookup 0x9c · transLookup 0xa4 · texAnimLookup 0xac), NOT @@ -139,6 +141,7 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result { // ElwynnTallWaterfall01 (2 transforms, batch combos 0/1) has [0,1] at 0xac, and // StormwindMagePortal01 (4 weight tracks) has the identity [0,1,2,3] at 0xa4 with [0] at 0x9c // — reading 0x9c as the transparency lookup silently dropped the portal's combo-1..3 tracks. + let texture_unit_lookup_arr = arr(p)?; p += 8; // header 0xa4: transparency_lookup (u16) — `weight_combo_index` indexes it to reach a weight track. let transparency_lookup_arr = arr(p)?; @@ -407,6 +410,19 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result { for i in 0..transparency_arr.0 as usize { transparency_tracks.push(track_fix16(b, transparency_arr.1 as usize + i * 0x1c)); } + let tulookup_avail = b.len().saturating_sub(texture_unit_lookup_arr.1 as usize); + let mut texture_unit_lookup = Vec::with_capacity(capped( + texture_unit_lookup_arr.0 as usize, + 2, + tulookup_avail, + )); + for i in 0..texture_unit_lookup_arr.0 as usize { + texture_unit_lookup.push( + get(texture_unit_lookup_arr.1 as usize + i * 2, 2)? + .u16_at(0) + .ok_or(Error::Truncated)?, + ); + } let tlookup_avail = b.len().saturating_sub(transparency_lookup_arr.1 as usize); let mut transparency_lookup = Vec::with_capacity(capped(transparency_lookup_arr.0 as usize, 2, tlookup_avail)); @@ -482,6 +498,7 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result { color_rgb_tracks, transparency_tracks, transparency_lookup, + texture_unit_lookup, texture_transforms, texture_transform_lookup, global_sequences, diff --git a/crates/benilla-m2/src/model.rs b/crates/benilla-m2/src/model.rs index f2f11510..1105098e 100644 --- a/crates/benilla-m2/src/model.rs +++ b/crates/benilla-m2/src/model.rs @@ -244,6 +244,13 @@ pub struct M2Model { /// The transparency lookup table (header `0xa4`, u16): `weight_combo_index` indexes this to reach /// [`Self::transparency_tracks`] (verified two-hop, wow-re `m2-alpha-combine-cull`). pub transparency_lookup: Vec, + /// The **texture-unit lookup** table (header `0x9c`, u16): `texture_coord_combo_index` (texUnit + /// `+0x12`) indexes this to decide where a stage's texture coordinates come from. A value + /// `< 3` names one of the vertex UV channels; **`>= 3` (real art: `0xffff`) is a *generated* + /// environment coordinate** — the model authors no usable UVs for that stage and the runtime + /// derives them from the view-space reflection vector. See + /// [`M2Model::stage_is_env_mapped`]. + pub texture_unit_lookup: Vec, /// The texture-transform records (see [`M2TextureTransform`]), selected via /// `texture_transform_combo_index → texture_transform_lookup → here`. pub texture_transforms: Vec, @@ -295,6 +302,27 @@ pub struct M2Model { } impl M2Model { + /// **Are this batch's stage-`stage` texture coordinates GENERATED (environment-mapped) rather + /// than read from the vertex UVs?** + /// + /// The reference's gate, at `0x70b8bd` (`cmp word[ [MD20+0xa0][texCoordSet + stage] ], 0x2; jbe` + /// — wow-re `m2-texanim-uv` §2, models.md §936/§944): a [`Self::texture_unit_lookup`] entry + /// `<= 2` names a vertex UV channel, anything higher (real art authors `0xffff`) is a + /// **generated environment coordinate**. Such a stage can never be `textureTransform`-driven, + /// and the shipped vertex program writes it as the sphere-map remap of the view-space + /// reflection vector — `uv = normalize(P − 2(P·N)N).xy · 0.5 + 0.5`, the `(0.5,0,0,0.5 / + /// 0,0.5,0,0.5)` matrix wow-re byte-derived at `0x70b8d0`. + /// + /// **An out-of-range index reads as env-mapped**, matching the reference: `0x70b8bd` indexes + /// the table unguarded and falls through to the same `else` branch that writes the env remap. + /// That is also the only safe direction — a model whose art relies on generated coordinates + /// authors *no usable UVs at all* (`GnomeSubwayGlass.m2`: all 330 vertices at exactly + /// `(0,0)`), so mistaking env for UV paints the whole mesh in a single corner texel. + pub fn stage_is_env_mapped(&self, batch: &crate::SkinBatch, stage: u16) -> bool { + let idx = batch.texture_coord_combo_index as usize + stage as usize; + self.texture_unit_lookup.get(idx).is_none_or(|&v| v > 2) + } + /// **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`. diff --git a/crates/benilla-m2/src/skin.rs b/crates/benilla-m2/src/skin.rs index 0778c6c7..1d3a44b6 100644 --- a/crates/benilla-m2/src/skin.rs +++ b/crates/benilla-m2/src/skin.rs @@ -27,6 +27,10 @@ pub struct SkinBatch { pub shader_id: u16, pub skin_section_index: u16, pub texture_combo_index: u16, + /// Index into [`crate::M2Model::texture_unit_lookup`] → where this stage's texture coordinates + /// come from (a UV channel, or a generated environment coordinate). texUnit `+0x12` + /// (wow-re rf72 `0x70b8a4`). + pub texture_coord_combo_index: u16, pub material_index: u16, /// Direct index into [`crate::M2Model::color_alpha_tracks`] (`0xffff` = none). texUnit `+0x08`. pub color_index: u16, @@ -116,8 +120,9 @@ impl M2Model { }); } // M2TextureUnit (batch): 24 bytes — skinSectionIndex@4, colorIndex@8, materialIndex@0xa, - // textureCount@0xe, textureComboIndex@0x10, weightComboIndex@0x14 (the visibility-factor - // refs), textureTransformComboIndex@0x16 (UV animation). + // textureCount@0xe, textureComboIndex@0x10, textureCoordComboIndex@0x12 (the env-vs-UV + // source), weightComboIndex@0x14 (the visibility-factor refs), textureTransformComboIndex@0x16 + // (UV animation). let bat_avail = bytes.len().saturating_sub(o_bat); let mut batches = Vec::with_capacity(capped(n_bat, 24, bat_avail)); for i in 0..n_bat { @@ -128,6 +133,7 @@ impl M2Model { skin_section_index: u.u16_at(4).ok_or(Error::Truncated)?, material_index: u.u16_at(0x0a).ok_or(Error::Truncated)?, texture_combo_index: u.u16_at(0x10).ok_or(Error::Truncated)?, + texture_coord_combo_index: u.u16_at(0x12).ok_or(Error::Truncated)?, color_index: u.u16_at(0x08).ok_or(Error::Truncated)?, weight_combo_index: u.u16_at(0x14).ok_or(Error::Truncated)?, texture_count: u.u16_at(0x0e).ok_or(Error::Truncated)?, diff --git a/crates/benilla/assets/shaders/wow_effect.wgsl b/crates/benilla/assets/shaders/wow_effect.wgsl index aaf9b0db..17efac04 100644 --- a/crates/benilla/assets/shaders/wow_effect.wgsl +++ b/crates/benilla/assets/shaders/wow_effect.wgsl @@ -75,6 +75,10 @@ struct VertexOutput { // View-space depth (+ in front of the camera) — the fog/farclip planar eye-Z (exact on // the cam-relative path, where the view transform is pure rotation). @location(2) view_z: f32, + // World-space offset from the eye — what `EFFECT_LIT` builds its camera-facing normal from. + // Already exactly `v.position` on the free-floating path (0733 §2's rebase); the decal path + // carries absolute verts, so it subtracts the eye back out. + @location(3) world_offset: vec3, }; @vertex @@ -91,6 +95,7 @@ fn vertex(v: Vertex) -> VertexOutput { out.clip_position = view.clip_from_world * vec4(v.position, 1.0); // Fog/farclip eye-Z via the full affine transform (yards-scale — rounding is irrelevant). out.view_z = -(view.view_from_world * vec4(v.position, 1.0)).z; + out.world_offset = v.position - view.world_position; #else // Free-floating families: cam-relative verts (0733 §2). view_from_world = [R | −R·cam]; // for a cam-relative point the translation column is exactly the subtraction prepare @@ -102,6 +107,7 @@ fn vertex(v: Vertex) -> VertexOutput { ) * v.position; out.clip_position = view.clip_from_view * vec4(view_pos, 1.0); out.view_z = -view_pos.z; + out.world_offset = v.position; #endif out.uv = v.uv; out.color = v.color; @@ -127,6 +133,36 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { // reference multiplies. let c = textureSample(effect_texture, effect_sampler, in.uv) * in.color; var rgb = c.rgb; +#ifdef EFFECT_LIT + // SCENE LIGHTING (EGxRs id 0x0e), for the emitters that ask for it. A particle emitter owns + // no material: the reference synthesizes an `M2Material` from the emitter's file record every + // draw (`0x70d8b0`) and runs the SAME batch state producer as an ordinary mesh submesh, so + // `GL_LIGHTING` lands on a particle quad exactly as on geometry — enabled iff the emitter + // CLEARS file flag 0x1 and its blend mode is not Mod/Mod2x (wow-re `part-scene-multipliers.md` + // §1, byte-verified at `0x70baf0` @`0x70bb00`; the gate itself is `EffectDrawSpec::lit`). + // + // File bit 0x1 is the UNLIT flag — the inverse of the wowdev-wiki lore — so the fire and spell + // corpus (which sets it) stays bright at night, while the environment sheets that clear it + // take the world's light: waterfall spray, chimney smoke, blown dust, snow. Rendering those + // unlit is a full-white cutout against shaded terrain (the Zul'Gurub waterfall foam). + // + // The term is the fixed-function matte `wow_model.wgsl` applies to a mesh — + // `clamp(ambient + diffuse·max(N·L, 0))` — against the CAMERA-FACING normal the reference's + // quad writer uploads for these emitters. On the cam-relative path a vertex position IS its + // offset from the eye in world axes, so the direction back to the camera is its negation; + // the degenerate at-the-eye case falls back to the sun-facing normal (full light), which is + // the limit the billboard approaches anyway. + let to_eye = -in.world_offset; + let len2 = dot(to_eye, to_eye); + let L = -normalize(wow_light.light_sun.xyz); + let N = select(L, to_eye * inverseSqrt(len2), len2 > 1e-12); + let lit = clamp( + wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * max(dot(N, L), 0.0), + vec3(0.0), + vec3(1.0), + ); + rgb = rgb * lit; +#endif // The scene day-night fog — byte-verified ON the particle path (wow-re // part-scene-multipliers.md): the reference runs the SAME per-vertex linear fog as world // geometry — same start/end, same day-night colour, applied in gamma space BEFORE blend, diff --git a/crates/benilla/assets/shaders/wow_model.wgsl b/crates/benilla/assets/shaders/wow_model.wgsl index ab272e9c..8a7e88ec 100644 --- a/crates/benilla/assets/shaders/wow_model.wgsl +++ b/crates/benilla/assets/shaders/wow_model.wgsl @@ -47,7 +47,6 @@ pbr_functions::alpha_discard, forward_io::{VertexOutput, FragmentOutput}, mesh_view_bindings::view, - view_transformations::position_world_to_clip, mesh_functions, } @@ -149,8 +148,9 @@ struct WowLight { // The owned skin palette (decision 0720; rig_palette.rs mirrors both sizes). `rig_table`: // one base bone index per rig slot (2048 = mesh_tag's 11-bit rig field; the instance's slot // rides its MeshTag bits 19-29). `palettes`: 3 vec4 rows per bone — the rows of - // `world_from_joint × inverse_bindpose`, the same matrix Bevy's skin lane would feed - // `skin_model` — blended in the vertex stage below (WOW_RIG_SKIN). + // `rig_from_joint × inverse_bindpose`, the same matrix Bevy's skin lane would feed + // `skin_model` except measured from the RIG's own origin rather than the map's (decision + // 0974) — blended in the vertex stage below (WOW_RIG_SKIN). rig_table: array, // The per-instance body TINT, on the SAME slot index as `rig_table` (instance_tint.rs, decision // 0812): the CM2 `model+0x184/188/18c` modulate colour, packed `0xFFRRGGBB` exactly as the @@ -158,6 +158,11 @@ struct WowLight { // — so slot 0 (every unskinned instance in the world), a zeroed studio buffer and an untinted // frame all cost nothing, while a genuine authored BLACK tint still reads as 0xFF000000. rig_tint: array, + // The rig ORIGIN table (decision 0974), on the SAME slot index again: `xyz` = the world + // position that rig's palette rows are measured from (`w` unused). The vertex stage adds it + // back as `origin − camera`, so a skinned vertex is never expressed as an absolute world + // coordinate in f32 — which is what the ~1 mm/frame character shimmer was. + rig_origin: array, 2048>, palettes: array>, }; @group(#{MATERIAL_BIND_GROUP}) @binding(90) var wow_light: WowLight; @@ -289,14 +294,19 @@ struct WowVertex { } #ifdef WOW_RIG_SKIN +// The instance's rig slot — the shared index into `rig_table`, `rig_tint` and `rig_origin`. +fn wow_rig_slot(instance_index: u32) -> u32 { + return (mesh_functions::get_tag(instance_index) >> 19u) & 0x7ffu; +} + // The owned-palette skin model (decision 0720): the instance's rig slot from its MeshTag rig // field (bits 19-29) → the rig's base bone index → the four indexed bones' palette rows blended -// by the vertex weights. Returns `world_from_local` exactly like Bevy's `skin_model` — the rows -// ARE `world_from_joint × inverse_bindpose`, so the reconstruction below is the same matrix -// Bevy's lane produced, and it REPLACES the mesh's world matrix (never composes with it). +// by the vertex weights. Returns `rig_from_local` — structurally what Bevy's `skin_model` returns +// (and it REPLACES the mesh's world matrix, never composes with it), except the translation is +// measured from the rig's own origin rather than the map's (decision 0974). `rig_origin[slot]` +// carries the missing piece; the vertex stage applies it camera-relative. fn wow_skin_model(instance_index: u32, indices: vec4, weights: vec4) -> mat4x4 { - let tag = mesh_functions::get_tag(instance_index); - let base = wow_light.rig_table[(tag >> 19u) & 0x7ffu]; + let base = wow_light.rig_table[wow_rig_slot(instance_index)]; let b0 = 3u * (base + indices.x); let b1 = 3u * (base + indices.y); let b2 = 3u * (base + indices.z); @@ -331,12 +341,14 @@ fn inverse_transpose_3x3m(in: mat3x3) -> mat3x3 { return mat3x3(x / det, y / det, z / det); } -fn wow_skin_normals(world_from_local: mat4x4, normal: vec3) -> vec3 { +// (Translation-free by construction — so the rig-relative frame of decision 0974 feeds it +// unchanged: a normal never cared where the rig stands.) +fn wow_skin_normals(frame_from_local: mat4x4, normal: vec3) -> vec3 { return normalize( inverse_transpose_3x3m(mat3x3( - world_from_local[0].xyz, - world_from_local[1].xyz, - world_from_local[2].xyz + frame_from_local[0].xyz, + frame_from_local[1].xyz, + frame_from_local[2].xyz )) * normal ); } @@ -352,19 +364,29 @@ fn vertex(vertex: WowVertex) -> WowVsOut { var out: WowVsOut; let mesh_world_from_local = mesh_functions::get_world_from_local(vertex.instance_index); + // **Split the instance placement into (frame, origin)** — decision 0974. `frame_from_local` + // carries the orientation and a SMALL translation; `frame_origin` carries the ~9 k-yard world + // position. Skinned: the palette rows are already rig-relative and `rig_origin` is the rig's + // world position. Unskinned: the mesh matrix's own translation column moves over. Either way + // the world position is `frame_from_local · v + frame_origin`, and the point is that neither + // factor is a big-times-small product — that product is where a ~1 mm f32 ULP was landing on + // every animated vertex, freshly every frame. #ifdef WOW_RIG_SKIN - var world_from_local = wow_skin_model( + var frame_from_local = wow_skin_model( vertex.instance_index, vertex.joint_indices, vertex.joint_weights ); + let frame_origin = wow_light.rig_origin[wow_rig_slot(vertex.instance_index)].xyz; #else - var world_from_local = mesh_world_from_local; + var frame_from_local = mesh_world_from_local; + let frame_origin = mesh_world_from_local[3].xyz; + frame_from_local[3] = vec4(0.0, 0.0, 0.0, 1.0); #endif #ifdef VERTEX_NORMALS #ifdef WOW_RIG_SKIN - out.world_normal = wow_skin_normals(world_from_local, vertex.normal); + out.world_normal = wow_skin_normals(frame_from_local, vertex.normal); #else out.world_normal = mesh_functions::mesh_normal_local_to_world( vertex.normal, @@ -374,9 +396,21 @@ fn vertex(vertex: WowVertex) -> WowVsOut { #endif #ifdef VERTEX_POSITIONS - out.world_position = - mesh_functions::mesh_position_local_to_world(world_from_local, vec4(vertex.position, 1.0)); - out.position = position_world_to_clip(out.world_position.xyz); + // Camera-relative all the way to clip space. `p_cam` is built from two small quantities, so + // it keeps ~1e-7 yd of precision where the absolute route kept ~1e-3 — and `clip_from_view × + // (R_view · p_cam)` has none of the catastrophic cancellation `clip_from_world × p_world` + // suffers when camera and geometry both sit at ~9 k (0733 §2 fixed the same defect on the + // effect lane; this is the model lane's). `world_position` goes back to absolute for the + // lighting/fog/shadow consumers downstream, which are not precision consumers. + let p_cam = (frame_from_local * vec4(vertex.position, 1.0)).xyz + + (frame_origin - view.world_position); + out.world_position = vec4(p_cam + view.world_position, 1.0); + let view_rot = mat3x3( + view.view_from_world[0].xyz, + view.view_from_world[1].xyz, + view.view_from_world[2].xyz, + ); + out.position = view.clip_from_view * vec4(view_rot * p_cam, 1.0); // WMO authored batch order (`m.sun_scale.y`; 0 = non-WMO ⇒ exact no-op): the client resolves // coplanar batches (wall + decal/trim) by strict MOBA draw order under depth-write + LEQUAL // (wow-5875-re `wmo-batch-blend-depth-state.md`, byte-verified); Bevy orders draws for @@ -390,6 +424,39 @@ fn vertex(vertex: WowVertex) -> WowVsOut { #ifdef VERTEX_UVS_A out.uv = vertex.uv; + // **Environment-mapped batches GENERATE their texcoord — here, in the VERTEX stage, because + // that is where the reference generates it.** `Shaders\Vertex\Model2.bls` writes it as a + // vertex-program output and lets the rasteriser interpolate: + // + // DP3 R0.x, R1.xyz, R2.xyz ; dot(P, N) P = view-space skinned position + // MUL R0.x, c0.w, R0.x ; ×2 N = normalize(view-space normal) + // MAD R0.yzw, -R0.x, R2.xxyz, R1.xxyz ; R = P − 2(P·N)N + // DP3/RSQ/MUL ; normalize(R) + // MAD result.texcoord[3].xy, R0.xyxx, c1.x, c1.x ; ·0.5 + 0.5 (c0.w = 2, c1.x = 0.5) + // + // — the `(0.5,0,0,0.5 / 0,0.5,0,0.5)` remap wow-re byte-derived at `0x70b8d0` (models.md §944), + // reached whenever `texture_unit_lookup[texCoordSet] > 2` (`0x70b8bd`). Its space is pinned by + // the same program: `c2` (projection alone) × `c31` × vertex = clip, so `c31` — and therefore + // P and N — are **view space**, and wow-re's `lookat_v1` (`0x5c3e70`) stores row0 = side, + // row1 = up, row2 = forward, so `R.xy` is (camera-right, camera-up). Bevy's view basis is + // −Z-forward, which is exactly `F = diag(1,1,−1)`: `P'·N' = P·N`, hence `R' = F·R` and `R'.xy` + // is **identical**. No handedness fixup is needed or wanted. + // + // `view_rot * p_cam` is the view-space position already built for clip space above, so this + // costs one normalize and reuses the camera-relative precision (0974) instead of round-tripping + // an absolute world coordinate. Decision 0971 evaluated this per FRAGMENT instead; measured on + // `GnomeSubwayGlass` the two differ by ≤0.004 UV across a whole ring (the sphere-map disk has + // radius 0.5), so the deviation bought nothing and cost fidelity — see decision 0980. +#ifdef VERTEX_POSITIONS +#ifdef VERTEX_NORMALS + if ((u32(m.clutter_fade.z) & 4096u) != 0u) { + let p_view = view_rot * p_cam; + let n_view = normalize(view_rot * out.world_normal); + let refl = normalize(p_view - 2.0 * dot(p_view, n_view) * n_view); + out.uv = refl.xy * 0.5 + vec2(0.5, 0.5); + } +#endif +#endif #endif #ifdef VERTEX_UVS_B out.uv_b = vertex.uv_b; @@ -446,7 +513,22 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> FragmentOutp vo.world_position = in.world_position; vo.world_normal = in.world_normal; #ifdef VERTEX_UVS_A - vo.uv = in.uv + m.sun_scale.zw; + // **Environment-mapped batches GENERATE their texture coordinates** (`clutter_fade.z` bit 12, + // model_render's `ENV_MAP_MARKER`; the asset's `texture_unit_lookup[texCoordSet] > 2`). Such a + // batch authors NO usable UVs — GnomeSubwayGlass's 330 vertices all sit at exactly (0,0), + // because the runtime is meant to supply them — so reading the raw vertex UV paints the whole + // surface in one corner texel of a reflection sheet (the Deeprun Tram tube's flat yellow). + // + // The coordinate itself is generated in the VERTEX stage, where the reference generates it + // (see the derivation there); `in.uv` already carries it, interpolated. All that is left here + // is to keep the UV **animation** off it: the reference's gate excludes an env stage from + // `textureTransform` by construction (`m2-texanim-uv` §2), so adding the live translation + // would drift a reflection that must stay pinned to the view. + if ((u32(m.clutter_fade.z) & 4096u) != 0u) { + vo.uv = in.uv; + } else { + vo.uv = in.uv + m.sun_scale.zw; + } #endif #ifdef VERTEX_UVS_B vo.uv_b = in.uv_b; diff --git a/crates/benilla/src/blob_shadow.rs b/crates/benilla/src/blob_shadow.rs index b24b42b4..f49bc7d1 100644 --- a/crates/benilla/src/blob_shadow.rs +++ b/crates/benilla/src/blob_shadow.rs @@ -486,6 +486,9 @@ fn push_shadows( texture: assets.texture.id(), blend: EffectBlend::Multiply, fog: EffectFog::Off, + // The modulate decal is its own darkening — the scene light is already in + // the ground it multiplies. + lit: false, anchor: key.feet, bias: SHADOW_SORT_BIAS, raster_bias: SHADOW_RASTER_BIAS, diff --git a/crates/benilla/src/capture/pick_probe.rs b/crates/benilla/src/capture/pick_probe.rs index b7776020..1b95a616 100644 --- a/crates/benilla/src/capture/pick_probe.rs +++ b/crates/benilla/src/capture/pick_probe.rs @@ -277,13 +277,21 @@ fn fire_pick( // An untagged batch — an equipped item's part or its billboard card — has no world // identity of its own; it is named by its blend + material + texture, which is what // separates "the pauldron's plate" from "the pauldron's camera-facing trim". + // `detail` is the inspector's second line — the kind-specific facts the model path can't + // carry (a WMO prop's lighting lane, an emitter count). Printed here because this probe + // IS the inspector without a cursor, and a prop that draws wrong usually differs from a + // right one only in that line (decision 0969: the black Booty Bay arch). let (kind, id, label) = obj.map_or_else( || ("".to_string(), String::new(), String::new()), |o| { ( format!("{:?}", o.kind), format!("#{}", o.id), - o.label.clone(), + if o.detail.is_empty() { + o.label.clone() + } else { + format!("{} [{}]", o.label, o.detail) + }, ) }, ); diff --git a/crates/benilla/src/collision.rs b/crates/benilla/src/collision.rs index fceeee60..cfa4b5f8 100644 --- a/crates/benilla/src/collision.rs +++ b/crates/benilla/src/collision.rs @@ -13,6 +13,8 @@ use avian3d::prelude::*; use bevy::ecs::component::Component; +pub(crate) mod one_sided; + /// The collision audiences. avian reserves bit 0 for [`CollisionLayer::Default`], which every collider /// without an explicit `CollisionLayers` belongs to — so terrain/doodad/GameObject colliders are seen by /// both the player and camera queries automatically. Only the two per-WMO meshes carry an explicit layer. diff --git a/crates/benilla/src/collision/one_sided.rs b/crates/benilla/src/collision/one_sided.rs new file mode 100644 index 00000000..e67f14e5 --- /dev/null +++ b/crates/benilla/src/collision/one_sided.rs @@ -0,0 +1,640 @@ +//! **One-sided movement collision** — the reference's facing law, applied at the only seam where it +//! can live: per candidate triangle, inside the sweep. +//! +//! The real client's movement collision is an orientation-blind gather followed by a strictly +//! one-sided resolver: `0x671cc0` emits every candidate face's plane at the **unflipped file +//! winding**, and `0x632700` opens each record with `dot = n·dir` against `[0x80c5c4]` +//! (`0xb727c5ac` = −9.99999975e-6) — **a face is processed iff `n·dir ≤ −1e-5`**. A face approached +//! from its back is discarded before any distance is computed, and this is not a wall-slide special +//! case: the 16 callers of `0x632ba0 earliest_contact` span falling, walking, step-up, ground-settle, +//! transports and the water-surface arm — "the surface you stand on is filtered exactly like the wall +//! you slide along" (decision 0967, wow-re `collision/scratch/wmo-movement-group-gate.md`; confirmed +//! at B86's exact pin in decision 0968). parry's trimesh is two-sided by construction, which is why +//! benilla stood on CoT's inward-wound shell where 1.12.1 falls through it. +//! +//! Why this is a mirrored loop and not a post-filter: a shape cast reports only the *first* hit. If +//! that hit is a backface, the correct result is the next front-face hit *behind* it — information a +//! finished cast no longer has. So the gate must run where candidates are enumerated, and avian's +//! enumeration (`cast_shape_predicate`, `contact_manifolds`) carries no triangle identity out. This +//! module therefore re-runs avian's own `move_and_slide` algorithm — same iteration structure, same +//! skin-width pull-back, same depenetration solver (`depenetrate_intersections`), same velocity +//! projection — over its public building blocks, walking each trimesh's BVH itself so every triangle +//! is gated on its **authored winding** before it may block. `Collider::trimesh` stores vertices and +//! indices exactly as passed (parry `TriMeshFlags::empty()`), and every transform between the file +//! and the collider is a proper rotation (decision 0968), so `TriMesh::triangle(i)`'s winding *is* +//! the authored winding. +//! +//! What is ported is the **law**, not the reference's resolver: the slide/step/snap machinery stays +//! the standard kinematic controller (that direction was closed in 0207), contact normals for edge +//! hits stay parry's, and convex (non-trimesh) colliders stay whole-shape — a convex volume has no +//! reachable backface. Depenetration — which the reference does not need (a one-sided swept world +//! never traps you) — keeps only its front-side half: a face may push the mover out along its +//! authored normal, never backwards through itself. The camera/LOS path is untouched *on purpose*: +//! the reference's segment kernel `0x7c29f0` is two-sided (0967), so avian's ordinary cast is +//! already faithful there. + +use avian3d::character_controller::move_and_slide::{ + DepenetrationConfig, MoveAndSlide, MoveAndSlideConfig, MoveAndSlideHitData, + MoveAndSlideHitResponse, MoveAndSlideOutput, MoveHitData, +}; +use avian3d::parry::bounding_volume::Aabb as ParryAabb; +use avian3d::parry::math::Pose3; +use avian3d::parry::query::{cast_shapes, contact, Ray, RayCast, ShapeCastOptions}; +use avian3d::prelude::*; +use bevy::prelude::*; +use core::time::Duration; + +/// The reference's facing gate: `[0x80c5c4] = 0xb727c5ac`. A face may block iff `n·dir ≤ EPS`, +/// with `n` the authored winding normal and `dir` the unit direction of the motion being resolved. +const FACING_EPS: f32 = f32::from_bits(0xb727_c5ac); + +/// avian's own stabilizer for `pull_back` when `n·dir` is nearly zero (`move_and_slide.rs`). +const DOT_EPSILON: f32 = 0.005; + +/// One-sided drop-in for [`MoveAndSlide::cast_move`]: sweep `shape` (world-axis-aligned, as every +/// mover capsule is) along `movement`, stopping `skin_width` short of the first face whose +/// **authored winding opposes the motion**. Backfaces are not candidates at all — the sweep passes +/// through them to whatever front-face lies beyond, which no post-filter on a first-hit cast can do. +pub(crate) fn cast_move( + ms: &MoveAndSlide<'_, '_>, + shape: &Collider, + from: Vec3, + movement: Vec3, + skin_width: f32, + filter: &SpatialQueryFilter, +) -> Option { + let (dir, len) = Dir3::new_and_length(movement).unwrap_or((Dir3::X, 0.0)); + let max_toi = len + skin_width; + + // The swept broad-phase box: the shape's AABB at both ends of the motion, grown by the skin. + let a0 = shape.aabb(from, Quat::IDENTITY); + let a1 = shape.aabb(from + movement, Quat::IDENTITY); + let swept = ColliderAabb { + min: a0.min.min(a1.min) - Vec3::splat(skin_width), + max: a0.max.max(a1.max) + Vec3::splat(skin_width), + }; + + let mut best: Option = None; + let mut best_toi = max_toi; + for entity in ms.spatial_query.aabb_intersections_with_aabb(swept) { + let Ok((collider, pos, rot, layers)) = ms.colliders.get(entity) else { + continue; + }; + if !filter.test(entity, layers.copied().unwrap_or_default()) { + continue; + } + + if let Some(trimesh) = collider.shape_scaled().as_trimesh() { + // Work in the trimesh's local frame: the cast pose and direction come in, world hit + // data goes out. A proper rotation preserves the sign of every `n·dir`. + let inv_rot = rot.0.inverse(); + let local_from = inv_rot * (from - pos.0); + let local_dir = inv_rot * *dir; + let local_pose = Pose3::from_parts(local_from, inv_rot); + for tri_id in trimesh + .bvh() + .intersect_aabb(&aabb_to_local(swept, pos.0, inv_rot)) + { + let tri = trimesh.triangle(tri_id); + let Some(n) = tri.normal() else { + continue; // degenerate face: no winding, no plane, nothing to block with + }; + if n.dot(local_dir) > FACING_EPS { + continue; // the law: a face approached from its back is not a candidate + } + let Ok(Some(hit)) = cast_shapes( + &Pose3::IDENTITY, + Vec3::ZERO, + &tri, + &local_pose, + local_dir, + shape.shape_scaled().as_ref(), + ShapeCastOptions { + max_time_of_impact: best_toi, + target_distance: 0.0, + stop_at_penetration: false, + compute_impact_geometry_on_penetration: true, + }, + ) else { + continue; + }; + if hit.time_of_impact < best_toi { + best_toi = hit.time_of_impact; + best = Some(MoveHitData { + entity, + distance: 0.0, // pulled back below, once, for the winner + point1: pos.0 + rot.0 * hit.witness1, + point2: pos.0 + rot.0 * (hit.witness2 + local_dir * hit.time_of_impact), + normal1: rot.0 * hit.normal1, + normal2: rot.0 * hit.normal2, + collision_distance: hit.time_of_impact, + }); + } + } + } else { + // A convex collider has no reachable backface: whole-shape sweep, exactly avian's. + let Ok(Some(hit)) = cast_shapes( + &Pose3::from_parts(pos.0, rot.0), + Vec3::ZERO, + collider.shape_scaled().as_ref(), + &Pose3::from_parts(from, Quat::IDENTITY), + *dir, + shape.shape_scaled().as_ref(), + ShapeCastOptions { + max_time_of_impact: best_toi, + target_distance: 0.0, + stop_at_penetration: false, + compute_impact_geometry_on_penetration: true, + }, + ) else { + continue; + }; + if hit.time_of_impact < best_toi { + best_toi = hit.time_of_impact; + best = Some(MoveHitData { + entity, + distance: 0.0, + point1: pos.0 + rot.0 * hit.witness1, + point2: from + hit.witness2 + *dir * hit.time_of_impact, + normal1: rot.0 * hit.normal1, + normal2: hit.normal2, + collision_distance: hit.time_of_impact, + }); + } + } + } + + best.map(|mut hit| { + // avian's skin-width pull-back: stop short of the surface by `skin / |n·dir|`, never negative. + hit.distance = if max_toi == 0.0 { + 0.0 + } else { + let dot = dir.dot(-hit.normal1).max(DOT_EPSILON); + (hit.collision_distance - skin_width / dot).max(0.0) + }; + hit + }) +} + +/// One-sided drop-in for [`SpatialQuery::cast_ray`], for the movement-law probes that are rays +/// rather than shape sweeps (the creature ground clamp mirrors the WALK resolver's Z re-derivation, +/// and that resolver filters its down-probe like every other arm — 0967's caller census). `dir` +/// must be a unit direction; the gate and the semantics are [`cast_move`]'s, minus the skin. +pub(crate) fn cast_ray( + ms: &MoveAndSlide<'_, '_>, + origin: Vec3, + dir: Dir3, + max_distance: f32, + filter: &SpatialQueryFilter, +) -> Option { + let end = origin + *dir * max_distance; + let swept = ColliderAabb { + min: origin.min(end), + max: origin.max(end), + }; + let mut best: Option = None; + let mut best_toi = max_distance; + for entity in ms.spatial_query.aabb_intersections_with_aabb(swept) { + let Ok((collider, pos, rot, layers)) = ms.colliders.get(entity) else { + continue; + }; + if !filter.test(entity, layers.copied().unwrap_or_default()) { + continue; + } + let inv_rot = rot.0.inverse(); + let local_ray = Ray::new(inv_rot * (origin - pos.0), inv_rot * *dir); + if let Some(trimesh) = collider.shape_scaled().as_trimesh() { + for tri_id in trimesh + .bvh() + .intersect_aabb(&aabb_to_local(swept, pos.0, inv_rot)) + { + let tri = trimesh.triangle(tri_id); + let Some(n) = tri.normal() else { + continue; + }; + if n.dot(local_ray.dir) > FACING_EPS { + continue; + } + let Some(hit) = tri.cast_local_ray_and_get_normal(&local_ray, best_toi, true) + else { + continue; + }; + if hit.time_of_impact < best_toi { + best_toi = hit.time_of_impact; + best = Some(RayHitData { + entity, + distance: hit.time_of_impact, + normal: rot.0 * hit.normal, + }); + } + } + } else { + let Some(hit) = collider + .shape_scaled() + .cast_local_ray_and_get_normal(&local_ray, best_toi, true) + else { + continue; + }; + if hit.time_of_impact < best_toi { + best_toi = hit.time_of_impact; + best = Some(RayHitData { + entity, + distance: hit.time_of_impact, + normal: rot.0 * hit.normal, + }); + } + } + } + best +} + +/// One-sided drop-in for [`MoveAndSlide::move_and_slide`]: avian's algorithm — depenetrate, sweep, +/// collect contact planes, project velocity, repeat, depenetrate — with every stage running over the +/// gated candidate set instead of the two-sided trimesh. The `on_hit` callback contract is avian's +/// ([`MoveAndSlideHitData`] / [`MoveAndSlideHitResponse`]), so the mover's ride/steep-wall handlers +/// move over unchanged. +#[allow(clippy::too_many_arguments)] +pub(crate) fn move_and_slide( + ms: &MoveAndSlide<'_, '_>, + shape: &Collider, + shape_position: Vec3, + mut velocity: Vec3, + delta_time: Duration, + config: &MoveAndSlideConfig, + filter: &SpatialQueryFilter, + mut on_hit: impl FnMut(MoveAndSlideHitData) -> MoveAndSlideHitResponse, +) -> MoveAndSlideOutput { + let mut position = shape_position; + let mut time_left = delta_time.as_secs_f32(); + let skin_width = ms.length_unit.0 * config.skin_width; + let depen_config: DepenetrationConfig = config.into(); + + position += depenetrate(ms, shape, position, &depen_config, filter); + + for _ in 0..config.move_and_slide_iterations { + let sweep = time_left * velocity; + let Ok((vel_dir, distance)) = Dir3::new_and_length(sweep) else { + break; + }; + const MIN_DISTANCE: f32 = 1e-4; + if distance < MIN_DISTANCE { + break; + } + + let Some(sweep_hit) = cast_move(ms, shape, position, sweep, skin_width, filter) else { + position += sweep; + break; + }; + + time_left -= time_left * (sweep_hit.distance / distance); + position += *vel_dir * sweep_hit.distance; + + let mut planes: Vec = config.planes.clone(); + let mut first_normal = Dir3::new_unchecked(sweep_hit.normal1); + let hit_response = on_hit(MoveAndSlideHitData { + entity: sweep_hit.entity, + point: sweep_hit.point2, + normal: &mut first_normal, + collision_distance: sweep_hit.collision_distance, + distance: sweep_hit.distance, + position: &mut position, + velocity: &mut velocity, + }); + if hit_response == MoveAndSlideHitResponse::Accept { + planes.push(first_normal); + } else if hit_response == MoveAndSlideHitResponse::Abort { + break; + } + + // Collect nearby contact planes for velocity clipping — avian's `intersections` pass, per + // gated triangle. The gate here is the same law with the *velocity* as the motion: a face + // may clip the slide iff its authored winding opposes where we are going AND we sit on its + // front side (behind a face, its plane does not exist for us — that is the fall-through). + let mut aborted = false; + for_each_contact( + ms, + shape, + position, + skin_width * 2.0, + filter, + Some(velocity), + |entity, point, normal, _dist| { + let mut normal = Dir3::new_unchecked(normal); + // Prune nearly-parallel planes, keeping the most blocking version (avian's rule). + for existing in planes.iter_mut() { + if normal.dot(**existing) >= config.plane_similarity_dot_threshold { + if normal.dot(velocity) < existing.dot(velocity) { + *existing = normal; + } + return true; + } + } + if planes.len() >= config.max_planes { + return false; + } + let hit_response = on_hit(MoveAndSlideHitData { + entity, + point, + normal: &mut normal, + collision_distance: sweep_hit.collision_distance, + distance: sweep_hit.distance, + position: &mut position, + velocity: &mut velocity, + }); + match hit_response { + MoveAndSlideHitResponse::Accept => { + planes.push(normal); + true + } + MoveAndSlideHitResponse::Ignore => true, + MoveAndSlideHitResponse::Abort => { + aborted = true; + false + } + } + }, + ); + + velocity = MoveAndSlide::project_velocity(velocity, &planes); + + if aborted { + break; + } + } + + position += depenetrate(ms, shape, position, &depen_config, filter); + + MoveAndSlideOutput { + position, + projected_velocity: velocity, + } +} + +/// One-sided depenetration: gather front-side contacts within the skin and hand them to avian's own +/// Gauss–Seidel solver ([`MoveAndSlide::depenetrate_intersections`]). A backface contact is dropped — +/// a face never ejects a body backwards through itself, which is what lets you *leave* an inward-wound +/// hull the way the reference never entered it. +fn depenetrate( + ms: &MoveAndSlide<'_, '_>, + shape: &Collider, + position: Vec3, + config: &DepenetrationConfig, + filter: &SpatialQueryFilter, +) -> Vec3 { + if config.depenetration_iterations == 0 { + return Vec3::ZERO; + } + let skin_width = ms.length_unit.0 * config.skin_width; + let mut intersections = Vec::new(); + for_each_contact( + ms, + shape, + position, + skin_width, + filter, + None, + |_, _, normal, dist| { + // avian pushes (normal, penetration + skin); parry's `dist` is negative when penetrating. + intersections.push((Dir3::new_unchecked(normal), -dist + skin_width)); + true + }, + ); + ms.depenetrate_intersections(config, &intersections) +} + +/// Visit every contact of `shape` (world-axis-aligned) against the filtered world within +/// `prediction`, gated by the one-sided law. `motion` selects the gate: +/// +/// - `Some(velocity)` — the slide's plane collection: a triangle participates iff its authored +/// normal opposes the velocity (`n·v̂ ≤ EPS`) *and* the mover sits on its front side. +/// - `None` — depenetration: the front-side test alone (no motion exists yet to oppose). +/// +/// The callback receives `(entity, world contact point, world contact normal toward the mover, +/// contact distance — negative when penetrating)` and returns `false` to stop the visit. Convex +/// colliders are visited ungated. +fn for_each_contact( + ms: &MoveAndSlide<'_, '_>, + shape: &Collider, + position: Vec3, + prediction: f32, + filter: &SpatialQueryFilter, + motion: Option, + mut callback: impl FnMut(Entity, Vec3, Vec3, f32) -> bool, +) { + let motion_dir = motion.and_then(|v| Dir3::new(v).ok()); + if motion.is_some() && motion_dir.is_none() { + return; // a zero velocity has nothing to clip + } + let a = shape.aabb(position, Quat::IDENTITY); + let grown = ColliderAabb { + min: a.min - Vec3::splat(prediction), + max: a.max + Vec3::splat(prediction), + }; + 'outer: for entity in ms.spatial_query.aabb_intersections_with_aabb(grown) { + let Ok((collider, pos, rot, layers)) = ms.colliders.get(entity) else { + continue; + }; + if !filter.test(entity, layers.copied().unwrap_or_default()) { + continue; + } + + if let Some(trimesh) = collider.shape_scaled().as_trimesh() { + let inv_rot = rot.0.inverse(); + let local_pose = Pose3::from_parts(inv_rot * (position - pos.0), inv_rot); + let local_motion = motion_dir.map(|d| inv_rot * *d); + for tri_id in trimesh + .bvh() + .intersect_aabb(&aabb_to_local(grown, pos.0, inv_rot)) + { + let tri = trimesh.triangle(tri_id); + let Some(n) = tri.normal() else { + continue; + }; + if let Some(local_dir) = local_motion { + if n.dot(local_dir) > FACING_EPS { + continue; + } + } + let Ok(Some(c)) = contact( + &Pose3::IDENTITY, + &tri, + &local_pose, + shape.shape_scaled().as_ref(), + prediction, + ) else { + continue; + }; + // Front-side gate: the contact must push the mover along the authored normal. + // Behind the face (`c.normal1 ≈ −n`) the plane does not exist for movement. + if c.normal1.dot(n) <= 0.0 { + continue; + } + if !callback(entity, pos.0 + rot.0 * c.point1, rot.0 * c.normal1, c.dist) { + break 'outer; + } + } + } else { + let Ok(Some(c)) = contact( + &Pose3::from_parts(pos.0, rot.0), + collider.shape_scaled().as_ref(), + &Pose3::from_parts(position, Quat::IDENTITY), + shape.shape_scaled().as_ref(), + prediction, + ) else { + continue; + }; + if !callback(entity, c.point1, c.normal1, c.dist) { + break 'outer; + } + } + } +} + +/// The conservative local-frame box of a world AABB under `world→local` = `inv_rot · (x − pos)`: +/// transform the eight corners and take their extent. +fn aabb_to_local(aabb: ColliderAabb, pos: Vec3, inv_rot: Quat) -> ParryAabb { + let (mut mins, mut maxs) = (Vec3::INFINITY, Vec3::NEG_INFINITY); + for i in 0..8 { + let corner = Vec3::new( + if i & 1 == 0 { aabb.min.x } else { aabb.max.x }, + if i & 2 == 0 { aabb.min.y } else { aabb.max.y }, + if i & 4 == 0 { aabb.min.z } else { aabb.max.z }, + ); + let local = inv_rot * (corner - pos); + mins = mins.min(local); + maxs = maxs.max(local); + } + ParryAabb::new(mins, maxs) +} + +#[cfg(test)] +mod tests { + use super::*; + use bevy::ecs::system::RunSystemOnce; + + /// A physics world holding one 10×10 quad at y = 0 with the given winding — `up: true` is a + /// floor (authored normal +Y), `up: false` is 0968's shell face (authored normal −Y). + fn world_with_quad(up: bool) -> App { + let mut app = App::new(); + // avian's collider backend reads `Assets` and `SceneSpawner` even in a meshless + // world, so the headless asset/scene plugins ride along. + app.add_plugins(( + MinimalPlugins, + bevy::transform::TransformPlugin, + bevy::asset::AssetPlugin::default(), + bevy::scene::ScenePlugin, + PhysicsPlugins::new(bevy::app::PostUpdate), + )); + app.init_asset::(); + let verts = vec![ + Vec3::new(-5.0, 0.0, -5.0), + Vec3::new(5.0, 0.0, -5.0), + Vec3::new(5.0, 0.0, 5.0), + Vec3::new(-5.0, 0.0, 5.0), + ]; + let tris = if up { + vec![[0u32, 2, 1], [0, 3, 2]] + } else { + vec![[0u32, 1, 2], [0, 2, 3]] + }; + app.world_mut().spawn(( + RigidBody::Static, + Collider::trimesh(verts, tris), + Transform::default(), + )); + // One frame builds Position/Rotation and the spatial-query trees. + app.update(); + app + } + + fn capsule() -> Collider { + Collider::capsule(0.4, 1.0) + } + + #[test] + fn a_floor_blocks_a_fall_and_a_backface_does_not() { + // The whole bug in one assertion pair: the same quad, same cast, opposite winding. + for (up, expect_block) in [(true, true), (false, false)] { + let hit = world_with_quad(up) + .world_mut() + .run_system_once(|ms: MoveAndSlide| { + cast_move( + &ms, + &capsule(), + Vec3::new(0.0, 3.0, 0.0), + Vec3::NEG_Y * 5.0, + 0.05, + &SpatialQueryFilter::default(), + ) + }) + .unwrap(); + assert_eq!( + hit.is_some(), + expect_block, + "winding up={up}: reference {} on this face", + if expect_block { + "blocks" + } else { + "falls through" + } + ); + if let Some(h) = hit { + // Capsule bottom = center − (0.5 + 0.4); surface at 0 ⇒ contact after ~2.1 yd, + // pulled back by the skin. + assert!((h.collision_distance - 2.1).abs() < 1e-3); + assert!(h.normal1.y > 0.99); + } + } + } + + #[test] + fn a_floor_is_no_ceiling_from_below() { + // Rising through an up-wound floor from beneath: its backface must not bump the head — + // and the down-wound quad, approached from below, is the face that must. + for (up, expect_block) in [(true, false), (false, true)] { + let hit = world_with_quad(up) + .world_mut() + .run_system_once(|ms: MoveAndSlide| { + cast_move( + &ms, + &capsule(), + Vec3::new(0.0, -3.0, 0.0), + Vec3::Y * 5.0, + 0.05, + &SpatialQueryFilter::default(), + ) + }) + .unwrap(); + assert_eq!(hit.is_some(), expect_block, "winding up={up} from below"); + } + } + + #[test] + fn the_slide_falls_through_a_backface_and_rests_on_a_floor() { + // The full resolve, not just the cast: one second of straight fall through/onto the quad. + // Backface: every stage — sweep, contact planes, depenetration — must let the body pass. + for (up, expect_above) in [(true, true), (false, false)] { + let end = world_with_quad(up) + .world_mut() + .run_system_once(|ms: MoveAndSlide| { + move_and_slide( + &ms, + &capsule(), + Vec3::new(0.0, 3.0, 0.0), + Vec3::NEG_Y * 10.0, + Duration::from_secs(1), + &MoveAndSlideConfig::default(), + &SpatialQueryFilter::default(), + |_| MoveAndSlideHitResponse::Accept, + ) + .position + }) + .unwrap(); + if expect_above { + assert!((end.y - 0.9).abs() < 0.1, "rests on the floor, got {end}"); + } else { + assert!(end.y < -6.0, "falls straight through, got {end}"); + } + } + } +} diff --git a/crates/benilla/src/creature_anim/compose.rs b/crates/benilla/src/creature_anim/compose.rs index e73d065e..20f8d6f9 100644 --- a/crates/benilla/src/creature_anim/compose.rs +++ b/crates/benilla/src/creature_anim/compose.rs @@ -12,10 +12,13 @@ //! //! **World pass** ([`finalize_rig_worlds`], post-propagation, inside //! [`crate::billboard::BillboardPlace`]): per rig needing it — pose-dirty, `joints_root` moved, -//! or camera-faced — compose the world chain `world_from_model × local…`, apply the byte-law -//! bone replacements (`billboard_joint_palette`'s math verbatim: the arm again, in its world-space -//! form, then billboard kinds take the camera basis, and descendants chain onto the replaced -//! frames), write the palette rows (`world × inverse_bindpose`, decision 0720), and re-seat the +//! or camera-faced — compose the chain `world_from_model × local…` **with the root's translation +//! zeroed** (decision 0974: the whole chain runs rig-relative, because composing it at the map's +//! ~9.5 k-yard coordinates spent an f32 ULP — ~1 mm — per matmul, freshly every frame), apply the +//! byte-law bone replacements (`billboard_joint_palette`'s math verbatim: the arm again, in its +//! world-space form, then billboard kinds take the camera basis, and descendants chain onto the +//! replaced frames), write the palette rows (`frame × inverse_bindpose`, decision 0720) beside the +//! rig's world origin (which the vertex stage adds back camera-relative), and re-seat the //! anchors sitting on replaced subtrees — including the same rigid-child re-walk and the same //! nested-rig do-not-enter rule as the entity pass. A re-seated subtree that carries another //! rig's model frame (the mounted rider's seat anchor, [`RigFrame`]) cascades: that rig @@ -71,10 +74,19 @@ fn compose_rig_models(mut rigs: Query<&mut RigPose>, mut anchors: Query<&mut Tra } } -/// One rig's world chain + which bones sit in a replaced subtree. `root_g` is the rig's -/// `joints_root` propagated world — the model's own root frame `[model+0xfc]`, which is both what -/// the `flags & 0x7` arm rebuilds a parent matrix out of and, for a mounted rider, its seat anchor -/// (`0x714389`). `cam` is the camera basis (`None` = no camera, so only the arm applies). +/// Translate a composed rig-relative frame back into world space (decision 0974). +fn shift(g: GlobalTransform, origin: Vec3) -> GlobalTransform { + let mut a = g.affine(); + a.translation += bevy::math::Vec3A::from(origin); + GlobalTransform::from(a) +} + +/// One rig's chain + which bones sit in a replaced subtree, composed **in the root's frame**: the +/// caller passes `root_g` with its translation zeroed, so every frame out is rig-relative +/// (decision 0974) and the whole chain runs at rig-sized magnitudes. `root_g` is otherwise the +/// rig's `joints_root` propagated world — the model's own root frame `[model+0xfc]`, which is both +/// what the `flags & 0x7` arm rebuilds a parent matrix out of and, for a mounted rider, its seat +/// anchor (`0x714389`). `cam` is the camera basis (`None` = no camera, so only the arm applies). /// The math mirrors `billboard_joint_palette` operation for operation — rewrite the parent matrix, /// compose the local, then face — so the collapsed lane is bit-compatible with the entity lane. fn rig_worlds( @@ -186,9 +198,20 @@ pub(crate) fn finalize_rig_worlds( let Ok(root_g) = globals.get(rig.joints_root).map(|(_, g)| *g) else { return; }; - let (worlds, touched) = rig_worlds(rig, root_g, cam_basis); + // **The chain is composed RIG-RELATIVE** (decision 0974): same root basis, translation + // zeroed. Every operation below — the `flags & 0x7` arm, the local compose, the + // billboard replacement — is translation-equivariant (the arm is 3×3 work plus a + // pivot-preserving translation; the billboard rewrites rotation only), so this yields + // exactly the world chain shifted by `−origin`, at rig-sized magnitudes. That is the + // fix: composing at Elwynn's ~9.5 k yards spent an f32 ULP (~1 mm) per matmul, freshly + // every frame because the pose is recomposed every frame, and that is the shimmer. + // (`WOW_NO_RIG_REBASE=1` makes this the map origin — the A/B lever for what the + // rebase is worth; see `rig_palette::rebase_origin`.) + let origin = crate::rig_palette::rebase_origin(root_g.translation()); + let root_rel = crate::rig_palette::rebase_global(root_g, origin); + let (worlds, touched) = rig_worlds(rig, root_rel, cam_basis); if let Some(ibp) = ibps.get(&skin.ibp) { - palettes.write_rig_worlds(skin, &worlds, ibp); + palettes.write_rig_worlds(skin, &worlds, ibp, origin); } if !rig.has_special && !root_moved { return; @@ -209,7 +232,11 @@ pub(crate) fn finalize_rig_worlds( if !root_moved && !touched.get(b).copied().unwrap_or(false) { continue; } - let Some(&world) = worlds.get(b) else { + // Anchors are ordinary scene-graph entities: their `GlobalTransform` is world + // space and every consumer (propagation, colliders, the effect lane, item + // placement) reads it as such — so the rig-relative frame goes back to absolute + // here. Only the PALETTE stays relative; that is the whole seam (decision 0974). + let Some(world) = worlds.get(b).map(|&w| shift(w, origin)) else { continue; }; if let Ok((_, mut g)) = globals.get_mut(anchor) { @@ -476,4 +503,124 @@ mod tests { ); } } + + /// **The jitter pin** (decision 0974) — the measurement the rebase exists for. + /// + /// Compose the same idling six-bone chain frame by frame two ways — rooted at Goldshire's real + /// world coordinates, and rooted at the rig's own origin with the position carried alongside — + /// and score each against an f64 oracle of the identical chain. What matters is not the error + /// but its **frame-to-frame variation**: a constant offset is invisible, a per-frame one is + /// shimmer. The absolute route re-rounds to the f32 grid at ~9.5 k yards (1 ULP ≈ 0.98 mm) + /// every frame *because the pose changes every frame* — that is the long-standing "characters + /// look jittery up close" the director reported, and terrain is exempt only because it doesn't + /// move. The rebased chain runs at rig-sized magnitudes where an ULP is ~0.1 µm. + /// + /// Pinned as a ratio and an absolute ceiling: someone re-rooting this chain in world space + /// again — the natural-looking simplification — puts the shimmer straight back. + #[test] + fn the_rebased_chain_does_not_re_round_at_world_scale_every_frame() { + use bevy::math::{DAffine3, DVec3}; + + // Goldshire. `.go xyz -9464 62 56 0` — near the worst |coord| the 1.12 maps reach, and + // exactly where the report came from. Deliberately not integers: 9464.0 is exact in f32. + const ROOT: Vec3 = Vec3::new(-9464.31, 62.17, 56.91); + const AMP: [f32; 6] = [0.010, 0.012, 0.008, 0.015, 0.020, 0.014]; // a breathing idle + + let sk = skeleton(vec![ + joint(-1, Vec3::ZERO), + joint(0, Vec3::new(0.0, 1.02, 0.0)), + joint(1, Vec3::new(0.0, 0.34, 0.0)), + joint(2, Vec3::new(0.21, 0.26, 0.0)), + joint(3, Vec3::new(0.0, -0.31, 0.0)), + joint(4, Vec3::new(0.0, -0.29, 0.0)), + ]); + let mut rig = RigPose::new(Entity::PLACEHOLDER, &sk); + let tip = sk.joints.len() - 1; + // A FACING, and bones that swing on two axes. Without both, a chain whose motion happens to + // miss the large world axis measures almost nothing — an all-about-X rig at this root left + // the ~9.5 k x-coordinate static and scored 40× better than it has any right to. A real + // skeleton's limbs move in all three world axes; the pin has to too. + let root_yaw = Quat::from_rotation_y(0.9); + + // The same chain in f64, from an identity root: the truth both routes are scored against. + let oracle = |rig: &RigPose| -> DVec3 { + let mut w: Vec = Vec::with_capacity(rig.locals.len()); + for i in 0..rig.locals.len() { + let l = DAffine3::from_scale_rotation_translation( + rig.locals[i].scale.as_dvec3(), + rig.locals[i].rotation.as_dquat(), + rig.locals[i].translation.as_dvec3(), + ); + w.push( + match usize::try_from(rig.parents[i]).ok().filter(|&p| p < i) { + Some(p) => w[p] * l, + None => l, + }, + ); + } + w[tip].translation + }; + + let root_abs = + GlobalTransform::from(Transform::from_translation(ROOT).with_rotation(root_yaw)); + let root_rel = GlobalTransform::from(Transform::from_rotation(root_yaw)); + let (mut abs_err, mut rel_err): (Vec, Vec) = (Vec::new(), Vec::new()); + let mut truths: Vec = Vec::new(); + for step in 0..180 { + let phase = std::f32::consts::TAU * step as f32 / 90.0; // a 1.5 s idle loop at 60 fps + for (i, amp) in AMP.iter().enumerate() { + let a = phase + i as f32 * 0.7; + rig.locals[i].rotation = + Quat::from_rotation_x(amp * a.sin()) * Quat::from_rotation_z(amp * a.cos()); + } + let truth = root_yaw.as_dquat() * oracle(&rig); + truths.push(truth); + let abs = rig_worlds(&rig, root_abs, None).0[tip].translation(); + let rel = rig_worlds(&rig, root_rel, None).0[tip].translation(); + abs_err.push(abs.as_dvec3() - (truth + ROOT.as_dvec3())); + rel_err.push(rel.as_dvec3() - truth); + } + let jitter = |e: &[DVec3]| e.windows(2).map(|w| w[1].distance(w[0])).sum::() / 179.0; + let (abs_j, rel_j) = (jitter(&abs_err), jitter(&rel_err)); + // The real per-frame motion of the tip — what the noise above has to be read against. + let signal = jitter(&truths); + + // `--nocapture` prints the measurement this pin was written from (yards): + // abs_err 5.2e-4 rel_err 9.6e-8 | abs_j 7.4e-4 rel_j 1.0e-7 | signal 1.6e-3 + // i.e. 0.74 mm of FRESH noise per frame against 1.65 mm of real motion — 45 % of a slow + // idle's apparent movement was rounding — and 1 Å after the rebase. + eprintln!( + "abs_err mean={:.3e} rel_err mean={:.3e} abs_j={abs_j:.3e} rel_j={rel_j:.3e} \ + signal={signal:.3e} noise/signal abs={:.2} rel={:.5}", + abs_err.iter().map(|e| e.length()).sum::() / 180.0, + rel_err.iter().map(|e| e.length()).sum::() / 180.0, + abs_j / signal, + rel_j / signal, + ); + // The sweep has to actually move the tip, or every number above is measuring nothing. + assert!( + signal > 1e-4, + "the idle really does move the tip: {signal:.3e}" + ); + assert!( + abs_j > 3e-4, + "the absolute route spends a world-scale ULP per frame — got {abs_j:.3e} yd. If this \ + ever fails, decision 0974's premise has stopped holding here (or the rig above has \ + drifted into missing the large world axis again) — re-derive before deleting." + ); + assert!( + abs_j / signal > 0.1, + "…and it is a large fraction of the real motion, which is why it READS as shake: \ + {:.2}", + abs_j / signal + ); + assert!( + rel_j < 1e-6, + "the rebased chain holds sub-micron — got {rel_j:.3e} yd" + ); + assert!( + abs_j / rel_j > 100.0, + "the rebase is worth ≥2 orders of magnitude: {abs_j:.3e} vs {rel_j:.3e} yd/frame" + ); + } } diff --git a/crates/benilla/src/doodad_anim/lazy.rs b/crates/benilla/src/doodad_anim/lazy.rs index d9d5bba3..dbe76c74 100644 --- a/crates/benilla/src/doodad_anim/lazy.rs +++ b/crates/benilla/src/doodad_anim/lazy.rs @@ -99,12 +99,24 @@ pub(super) fn promote_lazy_rig( }; let slot = rig.slot; if let Some(ibp) = ibps.get(&lazy.ibp) { + // Rig-relative like every other seed (decision 0974) — these joint worlds came off Bevy + // propagation, so the rebase only carries the convention here; it cannot un-spend the + // precision propagation already spent. The sweep rewrites the rows the same frame anyway. + let origin = crate::rig_palette::rebase_origin( + worlds + .get(root) + .map(|g| g.translation()) + .unwrap_or_default(), + ); let joint_worlds: Vec = lazy .joints .iter() - .map(|&j| worlds.get(j).copied().unwrap_or_default()) + .map(|&j| { + let g = worlds.get(j).copied().unwrap_or_default(); + crate::rig_palette::rebase_global(g, origin) + }) .collect(); - palettes.write_rig_worlds(&rig, &joint_worlds, ibp); + palettes.write_rig_worlds(&rig, &joint_worlds, ibp, origin); } // Queued, liveness-checked at APPLY time: the slot's only free path is the component's // `on_replace` hook, so a plain `insert` racing this frame's tile-unload despawn would drop diff --git a/crates/benilla/src/entities/attach/char_skin.rs b/crates/benilla/src/entities/attach/char_skin.rs index 0c96dd36..52069ed5 100644 --- a/crates/benilla/src/entities/attach/char_skin.rs +++ b/crates/benilla/src/entities/attach/char_skin.rs @@ -266,6 +266,10 @@ fn build_char_materials( // no blend-mode fog policy of its own (body/hair are always opaque/alpha-cut, which the // byte table fogs toward the scene colour anyway). benilla_formats::FogPolicy::Scene, + // …and no generated texcoords: a composite body/hair sheet is sampled by the body's + // own authored UVs. Env-mapping reaches a character through the ArmorReflect batches + // of its HELD items, which spawn as ordinary M2 submeshes and carry their own flag. + false, // Steady/fade: LIT like every entity M2 — the owner's dynamic MCSH shade reaches these // parts per instance through the MeshTag shade byte (`entity_shade`), not the shared // material. Interior-matte: the plain pair at sun ×1.0 — the null-node fallback. diff --git a/crates/benilla/src/entities/chain_beam.rs b/crates/benilla/src/entities/chain_beam.rs index 767c2a2f..9cc434bd 100644 --- a/crates/benilla/src/entities/chain_beam.rs +++ b/crates/benilla/src/entities/chain_beam.rs @@ -532,6 +532,7 @@ pub(crate) fn simulate_chain_beams( // Depth-write off and two-sided come with the lane's additive pipeline. blend: crate::particles::buffer::EffectBlend::Add, fog: EffectFog::Off, + lit: false, anchor: anchor_sum / anchor_n, // No owner rung: a beam is not a model's emitter (module docs, approximation (d)). bias: 0.0, diff --git a/crates/benilla/src/entities/display.rs b/crates/benilla/src/entities/display.rs index 1de4f574..f24ba8e2 100644 --- a/crates/benilla/src/entities/display.rs +++ b/crates/benilla/src/entities/display.rs @@ -465,6 +465,7 @@ pub(super) fn build_parts( sub.no_depth_write, // M2 render flag 0x10 sub.no_depth_test, // M2 render flag 0x08 sub.fog_policy, + sub.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs // Every entity M2 (unit/player/GameObject/held/fx) is built LIT — the verified // §9 chain gives them the same 2.5/0.5 lane as ADT doodads, and the DYNAMIC half // (the MCSH sample at their feet) rides the per-instance MeshTag shade byte @@ -532,6 +533,7 @@ pub(super) fn build_parts( sub.no_depth_write, sub.no_depth_test, sub.fog_policy, + sub.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs ShadeSel::Lit, // matches the exterior variant above order, None, // entities/portrait paths: UV anim deferred (0130 scope = placed doodads) @@ -562,6 +564,7 @@ pub(super) fn build_parts( sub.no_depth_write, sub.no_depth_test, sub.fog_policy, + sub.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs ShadeSel::Matte, // unread on the probe lane order, None, @@ -596,6 +599,7 @@ pub(super) fn build_parts( sub.no_depth_write, sub.no_depth_test, sub.fog_policy, + sub.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs ShadeSel::Matte, // unread on the probe lane order, None, @@ -625,6 +629,7 @@ pub(super) fn build_parts( sub.no_depth_write, sub.no_depth_test, sub.fog_policy, + sub.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs ShadeSel::Matte, // sun ×1.0, the forced indoor intensity — const so it dedups order, None, // entities/portrait paths: UV anim deferred (0130 scope = placed doodads) @@ -705,6 +710,7 @@ pub(super) fn build_parts( sub.no_depth_write, // false for WMO (standard depth state) sub.no_depth_test, sub.fog_policy, // always Scene for WMO + false, // WMO batches never env-map — the M2 texcoord mechanism has no MOMT analogue ShadeSel::Matte, // WMO uses the FFP N·L path, not the lobe — sun_scale unused 0, None, // entities/portrait paths: UV anim deferred (0130 scope = placed doodads) diff --git a/crates/benilla/src/ground_fx.rs b/crates/benilla/src/ground_fx.rs index 3ea5405b..a1aa5670 100644 --- a/crates/benilla/src/ground_fx.rs +++ b/crates/benilla/src/ground_fx.rs @@ -245,6 +245,9 @@ pub(crate) fn update_ground_fx_decals( texture: decal.texture.id(), blend: decal.blend, fog: decal.fog, + // Spell ground-fx art is authored to burn at its own colour (the lane law in + // `EffectBlend::from_model`); no lit ground quad observed in the corpus. + lit: false, anchor: decal.center, bias: GROUND_FX_DEPTH_BIAS, raster_bias: GROUND_FX_DEPTH_BIAS as i32, diff --git a/crates/benilla/src/lighting/global_light.rs b/crates/benilla/src/lighting/global_light.rs index f0283bef..1350d560 100644 --- a/crates/benilla/src/lighting/global_light.rs +++ b/crates/benilla/src/lighting/global_light.rs @@ -220,12 +220,13 @@ pub(crate) fn new_shared_light_buffer(device: &RenderDevice) -> SharedLightBuffe /// The full byte size of the shared light BUFFER: the per-frame blob ([`LightStd430`] — 19 header /// rows + the point-light table) PLUS the interior-prop probe region PLUS the skin-palette -/// regions (rig slot table + palette rows — decision 0720) at the tail. **Every buffer bound as +/// regions (rig slot table + tint table + rig-origin table + palette rows — decisions +/// 0720/0812/0974) at the tail. **Every buffer bound as /// `wow_light` must be at least this big** — `wow_model.wgsl` declares the whole layout, /// and wgpu validates bound size against the shader's struct at draw time. The portrait booth's /// frozen studio-light buffer sizes itself with this (its table regions stay zeroed ⇒ no scene /// point lights and black probes on portraits — the studio look is deliberately static); a -/// booth's PALETTE region is live, kept written by `rig_palette`'s mirror registry. +/// booth's PALETTE and ORIGIN regions are live, kept written by `rig_palette`'s mirror registry. pub(crate) fn light_blob_bytes() -> u64 { per_frame_blob_bytes() + (7 * MAX_PROP_PROBES * 16) as u64 diff --git a/crates/benilla/src/liquid/real_data.rs b/crates/benilla/src/liquid/real_data.rs index ff652138..a6137b6a 100644 --- a/crates/benilla/src/liquid/real_data.rs +++ b/crates/benilla/src/liquid/real_data.rs @@ -94,7 +94,7 @@ fn liquid_scene(map: &str, wow: [f32; 3]) -> Option { let Ok(tile) = benilla_formats::load_tile_mesh(&mut chain, map, tx, ty) else { continue; }; - for lq in tile.chunks.iter().filter_map(|c| c.liquid.as_ref()) { + for lq in tile.chunks.iter().flat_map(|c| c.liquids.iter()) { scene.surfaces.push(wet_footprint( lq, &Transform::IDENTITY, diff --git a/crates/benilla/src/model_render.rs b/crates/benilla/src/model_render.rs index 847d70da..9d4cc81c 100644 --- a/crates/benilla/src/model_render.rs +++ b/crates/benilla/src/model_render.rs @@ -63,6 +63,12 @@ pub(crate) struct MatKey { /// The batch's fog COLOUR policy ([`benilla_formats::FogPolicy`]) — packed into `clutter_fade.z` /// bits 4-6 (`wow_model.wgsl`'s step-5 fog). fog_policy: benilla_formats::FogPolicy, + /// This batch's texture coordinates are **generated** — the sphere-map environment coordinate, + /// not the vertex UVs ([`benilla_formats::RenderSubmesh::env_map`]). Packed into + /// `clutter_fade.z` bit 12; a key axis because two batches sharing a texture but differing here + /// sample it completely differently. **Not** a `WowModelKey` axis — it swaps which UV the + /// FRAGMENT stage samples, no pipeline state, so it mints no pipeline (decision 0837). + env_map: bool, /// The batch's static terrain-shade selector ([`ShadeSel`]). Static per placement, so it dedups a /// lit / matte / shaded material variant. shade: ShadeSel, @@ -178,6 +184,9 @@ pub(crate) fn model_material( no_depth_write: bool, no_depth_test: bool, fog_policy: benilla_formats::FogPolicy, + // This batch's texture coordinates are GENERATED, not authored + // ([`benilla_formats::RenderSubmesh::env_map`]) — see [`MatKey::env_map`]. + env_map: bool, shade: ShadeSel, // Authored batch index + 1 (0 = unordered): the transparent-pass ORDER of this batch among its // model's other batches — see [`MatKey::batch_order`] and [`BATCH_ORDER_SORT_EPS`]. @@ -205,6 +214,7 @@ pub(crate) fn model_material( no_depth_write, no_depth_test, fog_policy, + env_map, shade, batch_order, uv_anim: uv_anim.map(|a| std::sync::Arc::as_ptr(a) as usize), @@ -335,7 +345,8 @@ pub(crate) fn model_material( | (u16::from(fog_policy as u8) << 4) | (u16::from(blend == ModelBlend::Mod) << 7) | (u16::from(blend == ModelBlend::Mod2x) << 8) - | (u16::from(fade_variant && source_cutout) * TWIN_CUTOUT_MARKER), + | (u16::from(fade_variant && source_cutout) * TWIN_CUTOUT_MARKER) + | (u16::from(env_map) * ENV_MAP_MARKER), ), 0.0, ), @@ -344,7 +355,11 @@ pub(crate) fn model_material( // bypass lighting in wow_model.wgsl): the M2 UNLIT (0x01) flag, or WMO UNLIT on an // exterior-group batch (the interior drawer ignores it — section law, `wmo-lit-selector`). // Additive is NOT fullbright: the real client *lights* additive batches unless 0x01 is set - // (wow-re `m2-no-envmap-texgen`), so an un-flagged additive (e.g. ArmorReflect shine) is lit. + // (wow-re `m2-no-envmap-texgen`'s lighting section — `DAT_00811fa8[4] = 1`), so an + // un-flagged additive (e.g. ArmorReflect shine) is lit. **That note's headline is + // otherwise wrong and this cite reaches only its lighting table** (decision 0971): the + // M2 path DOES generate env-map texcoords — in the vertex program, which the note's + // `SetRenderState` sweep could not see. See [`ENV_MAP_MARKER`]. // M2 Mod/Mod2x ARE fullbright regardless of 0x01 — the lighting table // `DAT_00811fa8 = {1,1,1,1,1,0,0}` clears GL_LIGHTING for modes 5/6 (wow-re // `m2-depth-blend-state`); WMO lighting stays flag-driven only (decision 0528). @@ -433,6 +448,13 @@ pub(crate) const ZFILL_MARKER: u16 = 1 << 9; /// promoted alike (`m2-blend-promotion-zfill.md` §2; decision 0842). pub(crate) const TWIN_CUTOUT_MARKER: u16 = 1 << 10; +/// `clutter_fade.z` marker bit 12: this batch's texture coordinates are **generated**, so +/// `wow_model.wgsl` derives them from the view-space reflection vector instead of sampling the +/// (meaningless) vertex UVs — the reference's `texture_unit_lookup > 2` env stage. Bit 11 is +/// [`FAR_SIDE_MARKER`]. Deliberately **not** a [`crate::terrain::WowModelKey`] axis: it changes +/// only what the fragment stage samples, so it needs no pipeline of its own (decision 0837). +pub(crate) const ENV_MAP_MARKER: u16 = 1 << 12; + /// The twin's `Transparent3d` sort bias (yards; **negative = drawn earlier** — the sign law is /// `sky_order`'s module doc). It must clear the spread of one model's part AABB centres, so **all** /// of a fading model's twins draw before **any** of its colour parts — the reference achieves the @@ -485,6 +507,9 @@ pub(crate) fn zfill_material( no_depth_write: false, no_depth_test: false, fog_policy: benilla_formats::FogPolicy::Scene, + // A depth-prime twin writes no colour at all, so which UV it would have sampled cannot + // reach a pixel — one twin per source batch, whatever its texcoord source. + env_map: false, shade: ShadeSel::Lit, batch_order: 0, uv_anim: None, diff --git a/crates/benilla/src/net/motion/spline.rs b/crates/benilla/src/net/motion/spline.rs index e332af59..3f3ea35d 100644 --- a/crates/benilla/src/net/motion/spline.rs +++ b/crates/benilla/src/net/motion/spline.rs @@ -5,7 +5,7 @@ use std::sync::OnceLock; use std::time::{Duration, Instant}; -use avian3d::prelude::SpatialQuery; +use avian3d::character_controller::move_and_slide::MoveAndSlide; use benilla_assets::coords::{bevy_to_wow, wow_to_bevy}; use benilla_protocol::{CreateSpline, EntityKind}; use bevy::prelude::*; @@ -417,7 +417,7 @@ const GROUND_CLAMP_DOWN: f32 = 4.0; /// what we re-ground; an idle unit (no spline) is re-grounded in place each frame, which also catches /// it once its terrain collider loads. pub(in crate::net) fn ground_clamp_creatures( - spatial: SpatialQuery, + ms: MoveAndSlide, mut q: Query<( &NetEntity, Option<&Spline>, @@ -438,7 +438,12 @@ pub(in crate::net) fn ground_clamp_creatures( } let origin = t.translation + Vec3::Y * GROUND_CLAMP_UP; let reach = GROUND_CLAMP_UP + GROUND_CLAMP_DOWN; - if let Some(hit) = spatial.cast_ray(origin, Dir3::NEG_Y, reach, true, &filter) { + // The one-sided down-ray (decision 0970): a creature grounds like the player grounds — a + // face whose winding points away is no floor, or an idle NPC would stand mid-air on the + // very shell face the player mover now falls through. + if let Some(hit) = + crate::collision::one_sided::cast_ray(&ms, origin, Dir3::NEG_Y, reach, &filter) + { t.translation.y = origin.y - hit.distance; // the down-ray's hit point Y = feet on the surface } } diff --git a/crates/benilla/src/particles/buffer.rs b/crates/benilla/src/particles/buffer.rs index 3e882fdd..1f955e4c 100644 --- a/crates/benilla/src/particles/buffer.rs +++ b/crates/benilla/src/particles/buffer.rs @@ -191,6 +191,8 @@ pub struct EffectDraw { pub blend: EffectBlend, pub topology: EffectTopology, pub fog: EffectFog, + /// Does the scene's light multiply this draw's RGB? See [`EffectDrawSpec::lit`]. + pub lit: bool, /// The cloud's sort point — the emitter anchor / ribbon head node / decal center, exactly /// the sort point the material path used. pub anchor: Vec3, @@ -257,6 +259,19 @@ pub struct EffectDrawSpec { pub texture: AssetId, pub blend: EffectBlend, pub fog: EffectFog, + /// Multiply this draw's RGB by the scene's matte light — `clamp(ambient + diffuse·max(N·L,0))` + /// against a camera-facing normal, the same term `wow_model.wgsl` applies to a mesh. + /// + /// The lane is unlit by default and every family but one passes `false`: ribbons, decals, the + /// rings/reticle and precipitation are all authored to burn at their own colour. **M2 particle + /// emitters are the exception** — the reference has no particle material, it synthesizes an + /// `M2Material` from the emitter's file record every draw and runs the ordinary batch state + /// producer over it, so `GL_LIGHTING` lands on a particle exactly as on a mesh + /// ([`benilla_formats::ParticleEmitterDef::lit`], byte law there). 400 of the corpus's 7792 + /// emitters clear the unlit bit, nearly all of them `World\` environment sheets — waterfall + /// spray, chimney smoke, blown dust, snow — which read as full-white cutouts against shaded + /// terrain when the term is missing (the Zul'Gurub waterfall foam). + pub lit: bool, pub anchor: Vec3, pub bias: f32, pub raster_bias: i32, @@ -302,6 +317,7 @@ impl EffectQuads { blend: spec.blend, topology, fog: spec.fog, + lit: spec.lit, anchor: spec.anchor, bias: spec.bias, raster_bias: spec.raster_bias, @@ -414,6 +430,7 @@ mod tests { texture: AssetId::default(), blend: EffectBlend::Add, fog: EffectFog::Off, + lit: false, anchor: Vec3::ZERO, bias: 0.0, raster_bias: 0, diff --git a/crates/benilla/src/particles/census.rs b/crates/benilla/src/particles/census.rs index 160fa89f..c6ce2a4e 100644 --- a/crates/benilla/src/particles/census.rs +++ b/crates/benilla/src/particles/census.rs @@ -56,8 +56,14 @@ fn census( *last = time.elapsed_secs(); let mut per_cam: HashMap = HashMap::new(); + // How many of this frame's draws take the SCENE-LIT pipeline arm. Only ~5% of the emitter + // corpus clears the unlit file bit, so "the lit variant compiles" and "the lit variant ever + // runs" are entirely different claims — and a scene with no lit emitter in it cannot tell + // them apart. This counter is what makes the second one checkable. + let mut lit_draws = 0usize; for d in &quads.draws { *per_cam.entry(d.cam).or_default() += 1; + lit_draws += usize::from(d.lit); } let mut rows: Vec = per_cam .iter() @@ -82,10 +88,11 @@ fn census( .collect(); rows.sort(); info!( - "[fx-census] {} frames · {} verts · {} distinct buffer states · draws: {}", + "[fx-census] {} frames · {} verts · {} distinct buffer states · {} lit · draws: {}", *frames, quads.verts.len(), states.len(), + lit_draws, rows.join(", ") ); *frames = 0; diff --git a/crates/benilla/src/particles/emit.rs b/crates/benilla/src/particles/emit.rs index c7c5d6f4..03326f1a 100644 --- a/crates/benilla/src/particles/emit.rs +++ b/crates/benilla/src/particles/emit.rs @@ -174,6 +174,7 @@ pub(crate) mod tests { bone: 0, shape, blend: benilla_formats::ParticleBlend::Add, + lit: false, texture: None, tile_rows: 1, tile_cols: 1, diff --git a/crates/benilla/src/particles/model.rs b/crates/benilla/src/particles/model.rs index 464e1d25..59cad259 100644 --- a/crates/benilla/src/particles/model.rs +++ b/crates/benilla/src/particles/model.rs @@ -104,6 +104,7 @@ pub(super) fn update_model_particles( sub.no_depth_write, sub.no_depth_test, sub.fog_policy, + sub.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs // The same LIT lane as every entity M2 (the §9 chain). ShadeSel::Lit, 0, diff --git a/crates/benilla/src/particles/render.rs b/crates/benilla/src/particles/render.rs index b4e3cb45..a9c71e4a 100644 --- a/crates/benilla/src/particles/render.rs +++ b/crates/benilla/src/particles/render.rs @@ -78,6 +78,9 @@ struct ExtractedDraw { blend: EffectBlend, topology: EffectTopology, fog: EffectFog, + /// The scene-light gate — a pipeline-key axis, so the merge walk's `RunKey` separates lit + /// from unlit runs through the pipeline id alone. + lit: bool, anchor: Vec3, bias: f32, raster_bias: i32, @@ -184,6 +187,11 @@ pub struct EffectPipelineKey { /// keeps absolute verts and runs the mesh path's `clip_from_world`, so its depth ties /// against the drawn ground within the bias. raster_bias: i32, + /// Does the fragment multiply by the scene's matte light? The reference decides this per + /// emitter through a synthesized `M2Material` (`EffectDrawSpec::lit`), and it is a shader + /// def rather than a uniform bit so the 95% unlit majority keeps the identical instruction + /// stream it has today. + lit: bool, } impl SpecializedRenderPipeline for EffectPipeline { @@ -251,6 +259,11 @@ impl SpecializedRenderPipeline for EffectPipeline { if key.raster_bias != 0 { shader_defs.push("DECAL_WORLD_CLIP".into()); } + // The scene's matte light on this draw's RGB (the emitter's synthesized-material + // `GL_LIGHTING`; `EffectDrawSpec::lit` carries the byte law). + if key.lit { + shader_defs.push("EFFECT_LIT".into()); + } // `$WOW_PARTICLE_FLAT` — the fragment-input A/B (B16): solid magenta, no inputs. if std::env::var_os("WOW_PARTICLE_FLAT").is_some() { shader_defs.push("WOW_PARTICLE_FLAT".into()); @@ -338,6 +351,7 @@ fn extract_effects( blend: d.blend, topology: d.topology, fog: d.fog, + lit: d.lit, anchor: d.anchor, bias: d.bias, raster_bias: d.raster_bias, @@ -380,6 +394,7 @@ fn queue_effects( hdr: view.hdr, blend: draw.blend, raster_bias: draw.raster_bias, + lit: draw.lit, }, ); phase.add(Transparent3d { diff --git a/crates/benilla/src/particles/sim.rs b/crates/benilla/src/particles/sim.rs index c2ec80b8..83597f44 100644 --- a/crates/benilla/src/particles/sim.rs +++ b/crates/benilla/src/particles/sim.rs @@ -1072,6 +1072,7 @@ pub(super) fn simulate_particles( texture: texture.id(), blend: def.blend.into(), fog: EffectFog::for_blend(def.flags, def.blend), + lit: def.lit, anchor, bias, raster_bias: 0, @@ -1102,6 +1103,10 @@ pub(super) fn simulate_particles( texture: child.texture.id(), blend: child.def.blend.into(), fog: EffectFog::for_blend(child.def.flags, child.def.blend), + // A child emitter is a whole emitter record of the recursion model, with its + // own flag word and blend field — so it takes its OWN lighting verdict, never + // the parent's (the same rule its texture/blend/fog identity follows above). + lit: child.def.lit, anchor, bias, raster_bias: 0, diff --git a/crates/benilla/src/pipe_warm/menagerie.rs b/crates/benilla/src/pipe_warm/menagerie.rs index 39039787..2219e6b0 100644 --- a/crates/benilla/src/pipe_warm/menagerie.rs +++ b/crates/benilla/src/pipe_warm/menagerie.rs @@ -143,6 +143,9 @@ pub(super) fn spawn_menagerie( no_depth_write, no_depth_test, FogPolicy::Scene, + // Not a `WowModelKey` axis (it swaps a sampled UV, not pipeline state), so + // warming one side warms both. + false, ShadeSel::Lit, 0, None, @@ -178,6 +181,9 @@ pub(super) fn spawn_menagerie( no_depth_write, no_depth_test, FogPolicy::Scene, + // Not a `WowModelKey` axis (it swaps a sampled UV, not pipeline state), so + // warming one side warms both. + false, ShadeSel::Lit, 0, None, @@ -222,6 +228,9 @@ pub(super) fn spawn_menagerie( false, false, FogPolicy::Scene, + // Not a `WowModelKey` axis (it swaps a sampled UV, not pipeline state), so + // warming one side warms both. + false, ShadeSel::Lit, 0, None, @@ -266,6 +275,9 @@ pub(super) fn spawn_menagerie( no_depth_write, false, FogPolicy::Scene, + // Not a `WowModelKey` axis (it swaps a sampled UV, not pipeline state), so + // warming one side warms both. + false, ShadeSel::Lit, 0, None, @@ -661,6 +673,7 @@ fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh { no_depth_write: false, no_depth_test: false, fog_policy: FogPolicy::Scene, + env_map: false, billboard: None, welded_billboard: false, alpha_anim: None, diff --git a/crates/benilla/src/pipe_warm/mod.rs b/crates/benilla/src/pipe_warm/mod.rs index 47174d15..bba326ad 100644 --- a/crates/benilla/src/pipe_warm/mod.rs +++ b/crates/benilla/src/pipe_warm/mod.rs @@ -451,28 +451,36 @@ fn warm_effect_lane( crate::ground_fx::GROUND_FX_DEPTH_BIAS as i32, crate::blob_shadow::SHADOW_RASTER_BIAS, ] { - let start = quads.begin(); - for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] { - quads.verts.push(EffectVertex { - pos: [u * 0.01, v * 0.01, 0.0], - uv: [u, v], - color: [1.0, 1.0, 1.0, 1.0], - }); + // `lit` is a pipeline-key axis (a shader def), so BOTH arms are warmed — a hole + // here is a first-lit-emitter compile mid-play, which is the whole failure this + // module exists to prevent (0937's holes, 0958's blind lanes). The lit arm is + // rare content (400 of 7792 emitters) which is exactly why it would otherwise + // never be warm when it finally shows up. + for lit in [false, true] { + let start = quads.begin(); + for (u, v) in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] { + quads.verts.push(EffectVertex { + pos: [u * 0.01, v * 0.01, 0.0], + uv: [u, v], + color: [1.0, 1.0, 1.0, 1.0], + }); + } + quads.commit_quads( + start, + EffectDrawSpec { + cam, + texture: tex.id(), + blend, + fog: EffectFog::Off, + lit, + anchor: Vec3::ZERO, + bias: 0.0, + raster_bias, + main_entity: cam, + light: None, + }, + ); } - quads.commit_quads( - start, - EffectDrawSpec { - cam, - texture: tex.id(), - blend, - fog: EffectFog::Off, - anchor: Vec3::ZERO, - bias: 0.0, - raster_bias, - main_entity: cam, - light: None, - }, - ); } } } diff --git a/crates/benilla/src/player/mover.rs b/crates/benilla/src/player/mover.rs index 6c63613c..ae20a921 100644 --- a/crates/benilla/src/player/mover.rs +++ b/crates/benilla/src/player/mover.rs @@ -2,8 +2,10 @@ //! `control` system ([`super`] keeps the input/camera/wire glue and the knob table this reads). //! One call per frame: [`step`]. //! -//! Thin kinematic controller (decision 0009) over avian's `MoveAndSlide` — kept simple and robust -//! on the triangulated heightmap: +//! Thin kinematic controller (decision 0009) over the **one-sided** mirror of avian's +//! `MoveAndSlide` (`crate::collision::one_sided`, decision 0970: a face only blocks motion its +//! authored winding opposes, the reference's `0x632700` law) — kept simple and robust on the +//! triangulated heightmap: //! - probe down to classify the ground (walkable iff its normal is within ~50° of up); //! - "grounded" = on walkable ground AND not rising, so a jump cleanly leaves the ground (and //! isn't re-grounded the next frame — the bug that ate most jumps). While airborne the probe @@ -82,7 +84,7 @@ pub(super) fn step( // camera-only ones); the camera sweep uses its own filter (see `crate::collision`). let filter = player_query_filter(); let cast = |from: Vec3, disp: Vec3| { - ms.cast_move(capsule, from, Quat::IDENTITY, disp, SKIN_WIDTH, &filter) + crate::collision::one_sided::cast_move(ms, capsule, from, disp, SKIN_WIDTH, &filter) }; let probe_down = |c: Vec3, dist: f32| cast(c, Vec3::NEG_Y * dist); @@ -365,7 +367,7 @@ pub(crate) fn grounded_step( surface_offset: f32, ) -> GroundedStep { let cast = |from: Vec3, disp: Vec3| { - ms.cast_move(capsule, from, Quat::IDENTITY, disp, SKIN_WIDTH, filter) + crate::collision::one_sided::cast_move(ms, capsule, from, disp, SKIN_WIDTH, filter) }; let speed = horiz_vel.length(); // The step-up (decision 0209): ATOMIC — a steep face in the way triggers rise → @@ -390,10 +392,10 @@ pub(crate) fn grounded_step( snap: None, }; } - let out = ms.move_and_slide( + let out = crate::collision::one_sided::move_and_slide( + ms, capsule, center, - Quat::IDENTITY, horiz_vel, dt, &MoveAndSlideConfig::default(), @@ -473,10 +475,10 @@ pub(crate) fn airborne_step( velocity: Vec3, dt: std::time::Duration, ) -> Vec3 { - ms.move_and_slide( + crate::collision::one_sided::move_and_slide( + ms, capsule, center, - Quat::IDENTITY, velocity, dt, &MoveAndSlideConfig::default(), diff --git a/crates/benilla/src/player/swim.rs b/crates/benilla/src/player/swim.rs index 1ae3320d..2069c496 100644 --- a/crates/benilla/src/player/swim.rs +++ b/crates/benilla/src/player/swim.rs @@ -197,10 +197,10 @@ pub(super) fn breach_step( ) -> Outcome { player.vel_y = SWIM_JUMP_SPEED; let half_h = Vec3::Y * (CAPSULE_HEIGHT * 0.5); - let out = ms.move_and_slide( + let out = crate::collision::one_sided::move_and_slide( + ms, capsule, player.pos + half_h, - Quat::IDENTITY, player.horiz_vel + Vec3::Y * player.vel_y, time.delta(), &MoveAndSlideConfig::default(), @@ -333,10 +333,10 @@ pub(super) fn swim_step( }; let (vel, surface_pitch) = cap_redirect(input_vel, cap); - let out = ms.move_and_slide( + let out = crate::collision::one_sided::move_and_slide( + ms, capsule, center, - Quat::IDENTITY, vel, time.delta(), &MoveAndSlideConfig::default(), @@ -381,16 +381,15 @@ pub(super) fn swim_step( if let Some(surface_now) = surface_at(c - half_h) { let excess = settle_to_rest(c.y - half_h.y, surface_now, player.collision_height.0); if excess > 0.0 { - let drop = ms - .cast_move( - capsule, - c, - Quat::IDENTITY, - Vec3::NEG_Y * excess, - SKIN_WIDTH, - &filter, - ) - .map_or(excess, |h| h.distance.min(excess)); + let drop = crate::collision::one_sided::cast_move( + ms, + capsule, + c, + Vec3::NEG_Y * excess, + SKIN_WIDTH, + &filter, + ) + .map_or(excess, |h| h.distance.min(excess)); c.y -= drop; } } @@ -401,10 +400,10 @@ pub(super) fn swim_step( player.vel_y = 0.0; player.horiz_vel = Vec3::new(vel.x, 0.0, vel.z); - let probe = ms.cast_move( + let probe = crate::collision::one_sided::cast_move( + ms, capsule, c, - Quat::IDENTITY, Vec3::NEG_Y * GROUND_PROBE, SKIN_WIDTH, &filter, diff --git a/crates/benilla/src/portrait/glue_booth.rs b/crates/benilla/src/portrait/glue_booth.rs index f32a9340..3bfc9487 100644 --- a/crates/benilla/src/portrait/glue_booth.rs +++ b/crates/benilla/src/portrait/glue_booth.rs @@ -633,6 +633,7 @@ pub(super) fn sync_glue_scene( s.no_depth_write, s.no_depth_test, s.fog_policy, + s.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs crate::model_render::ShadeSel::Rig, // lit by the scene's own authored rig 0, s.uv_anim.as_ref(), // scene clouds/water scroll diff --git a/crates/benilla/src/portrait/test_bake.rs b/crates/benilla/src/portrait/test_bake.rs index 02a2ce72..e2441c47 100644 --- a/crates/benilla/src/portrait/test_bake.rs +++ b/crates/benilla/src/portrait/test_bake.rs @@ -137,6 +137,7 @@ fn bake_test( s.no_depth_write, s.no_depth_test, s.fog_policy, + s.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs crate::model_render::ShadeSel::Lit, // booth look: never ground-shaded 0, None, // static UVs diff --git a/crates/benilla/src/quest_markers/mod.rs b/crates/benilla/src/quest_markers/mod.rs index ead4b6de..19dd2385 100644 --- a/crates/benilla/src/quest_markers/mod.rs +++ b/crates/benilla/src/quest_markers/mod.rs @@ -374,6 +374,7 @@ fn build_markers( sub.no_depth_write, sub.no_depth_test, sub.fog_policy, + sub.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs ShadeSel::Lit, // a floating marker never inherits ground shade 0, None, // static UVs diff --git a/crates/benilla/src/ribbons.rs b/crates/benilla/src/ribbons.rs index bf537016..43de92f1 100644 --- a/crates/benilla/src/ribbons.rs +++ b/crates/benilla/src/ribbons.rs @@ -374,6 +374,11 @@ pub(crate) fn simulate_ribbons( // alpha/opaque trails fog toward the scene colour. (No ribbon authors the // particle "unfogged" file flag — pass 0.) fog: EffectFog::for_blend(0, def.blend), + // Ribbons keep the lane's unlit default: the M2 ribbon record has no flag word + // to read the particle path's unlit bit off, and the trail corpus is additive + // weapon/spell art authored to burn at its own colour. Revisit only with a + // byte law for the ribbon batch state, not by analogy with particles. + lit: false, anchor, // The owner rung, dropped under the water pass when the MODEL sits on the eye's // far side of its water plane — the model's bound centre with the bound-radius diff --git a/crates/benilla/src/rig_palette.rs b/crates/benilla/src/rig_palette.rs index 5b765cf4..5e92f3f4 100644 --- a/crates/benilla/src/rig_palette.rs +++ b/crates/benilla/src/rig_palette.rs @@ -12,13 +12,28 @@ //! and a `PrepareResources` upload that writes only what changed — here per-rig **dirty ranges** //! (a parked, stationary rig re-uploads nothing) plus the slot table on allocation changes. //! -//! **Palettes are world-space, Bevy's exact semantics**: row `b` of rig `r` is -//! `world_from_joint(b) × inverse_bindpose(b)` — the same matrix Bevy's `skin_model` would -//! produce — stored as 3 `vec4` rows (the affine's rows; the fourth row is implicit -//! `[0,0,0,1]`). The vertex stage reads the part's rig slot from its `MeshTag` rig field -//! (`mesh_tag::rig_bits`), resolves the rig's base bone index through the slot table, and blends -//! the four indexed bones' rows by the vertex weights — replacing (not composing with) the mesh's -//! world matrix, exactly like Bevy skinning. +//! **Palettes are RIG-RELATIVE** (decision 0974): row `b` of rig `r` is +//! `rig_from_joint(b) × inverse_bindpose(b)`, where `rig_from_joint` is the joint's world frame +//! with the rig's own world origin subtracted off — same 3×3 as Bevy's `skin_model` would +//! produce, translation measured from the rig root instead of the map's. The origin itself rides +//! a parallel per-slot table ([`rig_origin_region_offset`]), and the vertex stage +//! adds it back **camera-relative**, so a vertex never exists as an absolute world coordinate in +//! f32. That is the whole point: at Elwynn's ~9.5 k-yard coordinates one f32 ULP is ~1 mm, the +//! bone chain and the palette each spend one, and because the pose is recomposed every frame the +//! rounding is *fresh noise every frame* — the long-standing character shimmer. Rig-relative +//! numbers are ~1 yard, where an ULP is ~0.1 µm. (Rows stay 3 `vec4`s — the affine's rows; the +//! fourth is implicit `[0,0,0,1]`.) +//! +//! The vertex stage reads the part's rig slot from its `MeshTag` rig field (`mesh_tag::rig_bits`), +//! resolves the rig's base bone index through the slot table, and blends the four indexed bones' +//! rows by the vertex weights — replacing (not composing with) the mesh's world matrix, exactly +//! like Bevy skinning, then offsets by `origin − camera`. +//! +//! Both writers keep the convention — the collapsed lane ([`RigPalettes::write_rig_worlds`]) is +//! handed frames already composed from a zero-translation root, so its rows are exact; the entity +//! lane ([`RigPalettes::write_rig`]) subtracts the holder's world translation off globals Bevy +//! propagated in absolute space, so its rows inherit whatever rounding propagation already spent +//! (a named residual — killing it needs a floating origin, not a palette change). //! //! **Dirtiness is structural, not assumed**: every joint entity carries a [`RigJoint`] marker //! (planted by `entities::spawn_joints`), and [`compute_rig_palettes`] runs one linear @@ -58,18 +73,33 @@ pub(crate) fn rig_table_region_offset() -> u64 { crate::lighting::prop_probe_region_offset() + (7 * crate::lighting::MAX_PROP_PROBES * 16) as u64 } -/// Byte offset of the palette rows themselves — after the slot table AND the per-instance tint -/// table that shares this slot index ([`crate::instance_tint`], decision 0812). The rows stay last -/// because `wow_model.wgsl` declares them as the struct's one runtime-sized array. -pub(crate) fn palette_region_offset() -> u64 { +/// Byte offset of the per-slot rig ORIGIN table (decision 0974) — one `vec4` per slot, `xyz` = +/// the world position the rig's rows are measured from (`w` unused). Sits after the instance-tint +/// table, mirroring `wow_model.wgsl`'s struct order. +pub(crate) fn rig_origin_region_offset() -> u64 { crate::instance_tint::region_offset() + crate::instance_tint::region_bytes() } +/// Bytes the origin table adds (32 KB at 2048 slots). +fn rig_origin_region_bytes() -> u64 { + MAX_RIG_SLOTS as u64 * 16 +} + +/// Byte offset of the palette rows themselves — after the slot table, the per-instance tint table +/// that shares this slot index ([`crate::instance_tint`], decision 0812), and the origin table. +/// The rows stay last because `wow_model.wgsl` declares them as the struct's one runtime-sized +/// array. +pub(crate) fn palette_region_offset() -> u64 { + rig_origin_region_offset() + rig_origin_region_bytes() +} + /// Total bytes the slot-indexed regions add to every `wow_light`-layout buffer -/// (`lighting::light_blob_bytes`): the rig slot table, the instance-tint table, the palette rows. +/// (`lighting::light_blob_bytes`): the rig slot table, the instance-tint table, the origin table, +/// the palette rows. pub(crate) fn palette_regions_bytes() -> u64 { (MAX_RIG_SLOTS * 4) as u64 + crate::instance_tint::region_bytes() + + rig_origin_region_bytes() + MAX_PALETTE_BONES as u64 * BONE_BYTES } @@ -81,6 +111,14 @@ pub(crate) struct RigPalettes { rows: Arc>, /// Slot → base bone index. Slot 0 is the tag's "no rig" sentinel and never allocated. table: Arc>, + /// Slot → the world position this rig's rows are measured from (decision 0974); `w` unused. + /// Written by the same call that writes the rows, so the pair can never disagree. + origins: Arc>, + /// Bumped whenever any origin changes — the origin table's upload gate for the shared buffer. + origin_generation: u64, + /// Bumped only when a MIRRORED slot's origin changes, so a world full of walking creatures + /// does not re-push 32 KB into every booth buffer each frame. + origin_mirror_generation: u64, /// Per-slot bone count; 0 = free slot. slot_len: Vec, free_slots: Vec, @@ -115,6 +153,9 @@ impl Default for RigPalettes { Self { rows: Arc::new(vec![[0.0; 4]; 3 * MAX_PALETTE_BONES]), table: Arc::new(vec![0; MAX_RIG_SLOTS]), + origins: Arc::new(vec![[0.0; 4]; MAX_RIG_SLOTS]), + origin_generation: 0, + origin_mirror_generation: 0, slot_len: vec![0; MAX_RIG_SLOTS], mirrored: vec![false; MAX_RIG_SLOTS], free_slots: Vec::new(), @@ -160,6 +201,38 @@ fn rows_make_mut<'a>( Arc::make_mut(rows) } +/// Move a world affine into the rig's own frame (decision 0974): same 3×3, translation measured +/// from `origin`. The subtraction is where the ~9 k-yard magnitude leaves the number — everything +/// downstream of it is a rig-sized quantity. +fn rebase(mut world: Affine3A, origin: Vec3) -> Affine3A { + world.translation -= bevy::math::Vec3A::from(origin); + world +} + +/// [`rebase`]'s `GlobalTransform` face — for the lanes that hand +/// [`RigPalettes::write_rig_worlds`] frames Bevy propagated in absolute space, and for the +/// collapsed lane's root (which composes its chain *from* the rebased root). +pub(crate) fn rebase_global(g: GlobalTransform, origin: Vec3) -> GlobalTransform { + GlobalTransform::from(rebase(g.affine(), origin)) +} + +/// The origin a rig's rows are measured from, given its root's world translation — the ONE place +/// the decision-0974 rebase can be switched off, so no caller can hold half the convention. +/// +/// `WOW_NO_RIG_REBASE=1` is the A/B lever (the `WOW_NO_MOUNT_TILT` shape): it makes every rig's +/// origin the *map* origin, so the chain composes in absolute world space and the rows carry +/// ~9 k-yard translations again — the pre-0974 f32 rounding, and its shimmer, back on animated +/// characters. The shader needs no branch for it: `frame · v + (0 − camera)` spends the ULP inside +/// the big-times-small product exactly where the old route spent it inside `clip_from_world × +/// p_world`. Zero-cost when unset: one env read, once. +pub(crate) fn rebase_origin(root_world: Vec3) -> Vec3 { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + match *ON.get_or_init(|| std::env::var_os("WOW_NO_RIG_REBASE").is_none()) { + true => root_world, + false => Vec3::ZERO, + } +} + impl RigPalettes { /// Claim a slot + a bone range for a rig. `None` when either slab is exhausted — the caller /// warns and falls back to the static (bind-pose) mesh, graceful and visible in the log @@ -219,6 +292,9 @@ impl RigPalettes { ); rows[3 * base as usize..3 * (base + len) as usize].fill([0.0; 4]); self.dirty.push((base, len, self.mirrored[s])); + // …and the origin with them, so "collapsed at the origin" stays literally true for a + // stale tag during the one-frame despawn skew (decision 0974). + self.set_origin(slot, Vec3::ZERO); // Insert sorted + coalesce with both neighbours. let i = self.free_ranges.partition_point(|&(b, _)| b < base); self.free_ranges.insert(i, (base, len)); @@ -238,20 +314,43 @@ impl RigPalettes { } } + /// Publish the world position slot `slot`'s rows are measured from (decision 0974). Called by + /// both row writers with the same origin they subtracted, so the pair is atomic by + /// construction — there is no frame where the shader can add a stale origin to fresh rows. + fn set_origin(&mut self, slot: u16, origin: Vec3) { + let s = slot as usize; + let word = [origin.x, origin.y, origin.z, 0.0]; + match self.origins.get(s) { + Some(&cur) if cur != word => {} + _ => return, + } + Arc::make_mut(&mut self.origins)[s] = word; + self.origin_generation += 1; + if self.mirrored[s] { + self.origin_mirror_generation += 1; + } + } + /// Rewrite one rig's rows from its joints' world transforms × inverse bindposes, and mark the /// range dirty. `n` is clamped to the allocated length (a malformed model whose joint list /// and bindpose list disagree writes the shorter). + /// + /// `origin` is the rig holder's world translation: rows are written RELATIVE to it (decision + /// 0974) and the vertex stage adds it back. Unlike the collapsed lane, these globals were + /// propagated by Bevy in absolute world space, so subtracting the origin here cannot recover + /// precision propagation already spent — it buys the *clip-space* half of the fix only. Both + /// lanes must still agree on the convention, which is why it is not optional. fn write_rig( &mut self, - slot: u16, - base: u32, - alloc_len: u32, - joints: &[Entity], + rig: &RigSkin, ibp: &[Mat4], + origin: Vec3, worlds: &Query>, ) { - let n = (joints.len().min(ibp.len()) as u32).min(alloc_len); + let (slot, base, joints) = (rig.slot, rig.base, &rig.joints); + let n = (joints.len().min(ibp.len()) as u32).min(rig.len); self.cost_rows += n; + self.set_origin(slot, origin); let rows = rows_make_mut( &mut self.rows, &mut self.cost_copies, @@ -261,7 +360,7 @@ impl RigPalettes { let Ok(g) = worlds.get(joints[b]) else { continue; // a torn-down joint keeps its last rows }; - let m = g.affine() * Affine3A::from_mat4(ibp[b]); + let m = rebase(g.affine(), origin) * Affine3A::from_mat4(ibp[b]); let (m3, t) = (m.matrix3, m.translation); let r = 3 * (base as usize + b); rows[r] = [m3.x_axis.x, m3.y_axis.x, m3.z_axis.x, t.x]; @@ -275,18 +374,25 @@ impl RigPalettes { } /// The collapsed-rig lane's row write (decision 0724): the world pass hands the composed — - /// and billboard-replaced — per-bone world frames directly; rows are `world × ibp`, the same - /// product [`Self::write_rig`] reads off joint entities. `worlds` beyond the allocation (or - /// the bindpose list) is ignored, mirroring the entity path's clamp. + /// and billboard-replaced — per-bone frames directly; rows are `frame × ibp`, the same product + /// [`Self::write_rig`] reads off joint entities. `worlds` beyond the allocation (or the + /// bindpose list) is ignored, mirroring the entity path's clamp. + /// + /// The frames arrive already **rig-relative** (decision 0974) — the world pass composes the + /// chain from a zero-translation root — so nothing here spends a ~9 k-yard ULP and the rows + /// are exact to a rig-sized quantity. `origin` is the world position they are measured from; + /// the vertex stage adds it back camera-relative. pub(crate) fn write_rig_worlds( &mut self, rig: &RigSkin, worlds: &[GlobalTransform], ibp: &[Mat4], + origin: Vec3, ) { let n = (worlds.len().min(ibp.len()) as u32).min(rig.len); let (slot, base) = (rig.slot, rig.base); self.cost_rows += n; + self.set_origin(slot, origin); let rows = rows_make_mut( &mut self.rows, &mut self.cost_copies, @@ -313,10 +419,18 @@ impl RigPalettes { if let Some(m) = self.mirrored.get_mut(slot as usize) { *m = true; } + // Push the ORIGIN table to the mirrors on this edge, unconditionally (decision 0974). The + // mirror upload is gated on `origin_mirror_generation`, which only moves when a slot that + // is ALREADY mirrored changes — so a booth rig whose origin was written before this call + // (or written again to the same value after it) would leave the studio buffers holding a + // zero origin, and the booth's subject would render an Elwynn's-worth of yards away. + self.origin_mirror_generation += 1; } - /// The mouseover picker's read side: rig slot → world-space `Mat4` palette (the same values - /// Bevy's `skin_model` would compute), reconstructed from the rows. + /// The mouseover picker's read side: rig slot → **world-space** `Mat4` palette (the same + /// values Bevy's `skin_model` would compute), reconstructed from the rows. The stored rows are + /// rig-relative (decision 0974), so the slot's origin goes back on here — the picker ray is a + /// world-space ray, and it is not a precision consumer. pub(crate) fn world_palette(&self, slot: u16, bones: usize) -> Option> { let s = slot as usize; let len = (*self.slot_len.get(s)? as usize).min(bones); @@ -324,17 +438,17 @@ impl RigPalettes { return None; } let base = self.table[s] as usize; + let o = self.origins[s]; Some( (0..len) .map(|b| { let r = 3 * (base + b); - Mat4::from_cols( - Vec4::from_array(self.rows[r]), - Vec4::from_array(self.rows[r + 1]), - Vec4::from_array(self.rows[r + 2]), - Vec4::W, - ) - .transpose() + let row = |i: usize, off: f32| { + let mut v = self.rows[r + i]; + v[3] += off; + Vec4::from_array(v) + }; + Mat4::from_cols(row(0, o[0]), row(1, o[1]), row(2, o[2]), Vec4::W).transpose() }) .collect(), ) @@ -493,7 +607,17 @@ fn compute_rig_palettes( let Some(ibp) = ibps.get(&rig.ibp) else { continue; }; - palettes.write_rig(rig.slot, rig.base, rig.len, &rig.joints, ibp, &worlds); + // The rig holder's own world translation is the rebasing origin (decision 0974): every + // joint is a descendant of it, so the rows come out rig-sized. Any origin would be + // *correct* — only precision cares that it sits near the geometry — so a holder without a + // global (mid-teardown) safely falls back to the map origin, i.e. the old behaviour. + let origin = rebase_origin( + worlds + .get(root) + .map(|g| g.translation()) + .unwrap_or_default(), + ); + palettes.write_rig(rig, ibp, origin, &worlds); } } @@ -502,8 +626,11 @@ fn compute_rig_palettes( struct RigPaletteExtract { rows: Arc>, table: Arc>, + origins: Arc>, dirty: Arc>, table_generation: u64, + origin_generation: u64, + origin_mirror_generation: u64, } impl Default for RigPaletteExtract { @@ -511,16 +638,22 @@ impl Default for RigPaletteExtract { Self { rows: Arc::new(Vec::new()), table: Arc::new(Vec::new()), + origins: Arc::new(Vec::new()), dirty: Arc::new(Vec::new()), // != RigPalettes' initial 0, so the first publish uploads even an empty table. table_generation: u64::MAX, + origin_generation: u64::MAX, + origin_mirror_generation: u64::MAX, } } } /// Main world, after the compute: hand the frame's dirty ranges + the shared rows to extraction. fn publish_rig_palettes(mut palettes: ResMut, mut out: ResMut) { - if palettes.dirty.is_empty() && out.table_generation == palettes.table_generation { + if palettes.dirty.is_empty() + && out.table_generation == palettes.table_generation + && out.origin_generation == palettes.origin_generation + { return; } let p = palettes.as_mut(); @@ -536,8 +669,11 @@ fn publish_rig_palettes(mut palettes: ResMut, mut out: ResMut, ); -/// Render world (`PrepareResources`): write the dirty rows + (on change) the slot table into the -/// shared buffer and every registered mirror. A parked, stationary world costs zero bytes here. +/// What the last upload actually put on the GPU — the four independent change gates +/// ([`upload_rig_palettes`]). `dirty` is an `Arc` pointer, not a counter: a new publish makes a +/// new `Arc`. +#[derive(Default)] +struct UploadedGenerations { + table: Option, + dirty: u64, + origin: Option, + origin_mirror: Option, +} + +/// Render world (`PrepareResources`): write the dirty rows + (on change) the slot table and the +/// rig-origin table into the shared buffer and every registered mirror. A parked, stationary world +/// costs zero bytes here. fn upload_rig_palettes( queue: Res, shared: Option>, mirrors: Option>, data: Option>, - mut last: Local<(Option, u64)>, + mut last: Local, ) { let Some(data) = data else { return }; - // The extract clones every frame; gate on content. `last.1` counts published dirty batches + // The extract clones every frame; gate on content. `dirty` counts published dirty batches // via the Arc pointer (a new publish makes a new Arc). let dirty_ptr = Arc::as_ptr(&data.dirty) as u64; - let table_new = last.0 != Some(data.table_generation); - let dirty_new = last.1 != dirty_ptr && !data.dirty.is_empty(); - if !table_new && !dirty_new { + let table_new = last.table != Some(data.table_generation); + let dirty_new = last.dirty != dirty_ptr && !data.dirty.is_empty(); + // The origin table is 32 KB written whole (decision 0974): tiny beside the rows, and the + // mirrors take their own generation so a world full of walking creatures does not re-push it + // into every booth buffer each frame. + let origin_new = last.origin != Some(data.origin_generation) && !data.origins.is_empty(); + let origin_mirror_new = + last.origin_mirror != Some(data.origin_mirror_generation) && !data.origins.is_empty(); + if !table_new && !dirty_new && !origin_new && !origin_mirror_new { return; } - *last = (Some(data.table_generation), dirty_ptr); + *last = UploadedGenerations { + table: Some(data.table_generation), + dirty: dirty_ptr, + origin: Some(data.origin_generation), + origin_mirror: Some(data.origin_mirror_generation), + }; let cost_t0 = rig_cost_enabled().then(std::time::Instant::now); let mut cost_calls = 0u32; let mut cost_bytes = 0u64; @@ -595,6 +754,19 @@ fn upload_rig_palettes( bytemuck::cast_slice(&data.table), ); } + if if mirror_only { + origin_mirror_new + } else { + origin_new + } { + queue.write_buffer( + buffer, + rig_origin_region_offset(), + bytemuck::cast_slice(&data.origins), + ); + cost_calls += 1; + cost_bytes += rig_origin_region_bytes(); + } if dirty_new { let ranges = if mirror_only { &mirrored_only } else { &all }; for &(base, len) in ranges { @@ -800,29 +972,55 @@ mod tests { assert!(p.alloc(1).is_some()); } + /// The picker's read side puts the slot's ORIGIN back on (decision 0974) — rows are stored + /// rig-relative, so a reconstruction that forgot the origin would ray-test every skinned unit + /// against a copy of itself sitting at the map origin. #[test] - fn world_palette_reconstructs_the_affine() { + fn world_palette_reconstructs_the_affine_including_the_rig_origin() { let mut p = RigPalettes::default(); let (slot, base) = p.alloc(1).unwrap(); - // A recognisable affine: rotate 90° about Y, translate (1,2,3). + // A recognisable affine: rotate 90° about Y, translate (1,2,3) — inside a rig standing a + // long way from the map origin. + let origin = Vec3::new(-9464.31, 62.17, 56.91); let m = Mat4::from_rotation_translation( Quat::from_rotation_y(std::f32::consts::FRAC_PI_2), Vec3::new(1.0, 2.0, 3.0), ); let a = Affine3A::from_mat4(m); + p.set_origin(slot, origin); let rows = Arc::make_mut(&mut p.rows); let (m3, t) = (a.matrix3, a.translation); let r = 3 * base as usize; rows[r] = [m3.x_axis.x, m3.y_axis.x, m3.z_axis.x, t.x]; rows[r + 1] = [m3.x_axis.y, m3.y_axis.y, m3.z_axis.y, t.y]; rows[r + 2] = [m3.x_axis.z, m3.y_axis.z, m3.z_axis.z, t.z]; + let want = Mat4::from_translation(origin) * m; let pal = p.world_palette(slot, 1).unwrap(); - assert!(pal[0].abs_diff_eq(m, 1e-6), "{:?} vs {m:?}", pal[0]); + assert!(pal[0].abs_diff_eq(want, 1e-2), "{:?} vs {want:?}", pal[0]); // A transformed point round-trips through the reconstruction. let q = Vec3::new(0.5, -1.0, 2.0); assert!(pal[0] .transform_point3(q) - .abs_diff_eq(m.transform_point3(q), 1e-5)); + .abs_diff_eq(want.transform_point3(q), 1e-2)); + // And the origin is really load-bearing here, not a rounding-sized detail. + assert!( + p.world_palette(slot, 1).unwrap()[0] + .w_axis + .truncate() + .distance(origin + Vec3::new(1.0, 2.0, 3.0)) + < 1e-2 + ); + } + + /// A slot recycles: the freed rig's origin must not ride into the next unit that claims it. + #[test] + fn freeing_a_slot_clears_its_origin() { + let mut p = RigPalettes::default(); + let (slot, _) = p.alloc(4).unwrap(); + p.set_origin(slot, Vec3::new(-9464.31, 62.17, 56.91)); + assert_ne!(p.origins[slot as usize], [0.0; 4]); + p.free(slot); + assert_eq!(p.origins[slot as usize], [0.0; 4]); } #[test] @@ -837,27 +1035,36 @@ mod tests { #[test] fn region_layout_is_consistent() { - // The slot table sits after the probe region, the per-instance tint table (decision 0812 — - // same slot index) after it, the palette rows last, and the blob-size extension covers all - // three exactly (wow_model.wgsl mirrors this layout, and wgpu validates the bound size - // against the shader's struct at draw time — a mismatch here is a draw-time failure, which - // is why this is asserted rather than trusted). + // The slot table sits after the probe region, then the per-instance tint table (decision + // 0812 — same slot index), then the rig-origin table (decision 0974 — same slot index + // again), the palette rows last, and the blob-size extension covers all four exactly + // (wow_model.wgsl mirrors this layout, and wgpu validates the bound size against the + // shader's struct at draw time — a mismatch here is a draw-time failure, which is why this + // is asserted rather than trusted). assert_eq!( crate::instance_tint::region_offset() - rig_table_region_offset(), (MAX_RIG_SLOTS * 4) as u64, "the tint table follows the slot table" ); assert_eq!( - palette_region_offset() - crate::instance_tint::region_offset(), + rig_origin_region_offset() - crate::instance_tint::region_offset(), crate::instance_tint::region_bytes(), - "the palette rows follow the tint table" + "the rig-origin table follows the tint table" + ); + assert_eq!( + palette_region_offset() - rig_origin_region_offset(), + rig_origin_region_bytes(), + "the palette rows follow the origin table" ); assert_eq!( palette_regions_bytes(), (MAX_RIG_SLOTS * 4) as u64 + crate::instance_tint::region_bytes() + + rig_origin_region_bytes() + (MAX_PALETTE_BONES as u64) * BONE_BYTES ); + // One `vec4` per addressable slot — the shader declares `array, 2048>`. + assert_eq!(rig_origin_region_bytes(), (MAX_RIG_SLOTS * 16) as u64); // One word per addressable slot — the shader declares `array` for both tables. assert_eq!( crate::instance_tint::region_bytes(), diff --git a/crates/benilla/src/target/reticle.rs b/crates/benilla/src/target/reticle.rs index 5b7de6ae..d7bab94e 100644 --- a/crates/benilla/src/target/reticle.rs +++ b/crates/benilla/src/target/reticle.rs @@ -206,6 +206,7 @@ pub(super) fn push_reticle( texture, blend: EffectBlend::Alpha, fog: EffectFog::Off, + lit: false, anchor: state.key.center, bias: RETICLE_DEPTH_BIAS, raster_bias: RETICLE_DEPTH_BIAS as i32, diff --git a/crates/benilla/src/target/ring.rs b/crates/benilla/src/target/ring.rs index bd71709d..5e1a445a 100644 --- a/crates/benilla/src/target/ring.rs +++ b/crates/benilla/src/target/ring.rs @@ -522,6 +522,7 @@ pub(super) fn push_ring( texture: assets.texture.id(), blend: EffectBlend::Add, fog: EffectFog::Off, + lit: false, anchor: state.key.feet, bias: RING_DEPTH_BIAS, raster_bias: RING_DEPTH_BIAS as i32, diff --git a/crates/benilla/src/terrain_stream.rs b/crates/benilla/src/terrain_stream.rs index 96d2b0e5..79487c12 100644 --- a/crates/benilla/src/terrain_stream.rs +++ b/crates/benilla/src/terrain_stream.rs @@ -611,7 +611,7 @@ fn stream_terrain( let mut liquid_ents = Vec::new(); spawn_liquids( &mut commands, - adt.chunks.iter().filter_map(|c| c.liquid.as_ref()), + adt.chunks.iter().flat_map(|c| c.liquids.iter()), liquid_assets.as_deref(), &mut meshes, &mut liquid_ents, diff --git a/crates/benilla/src/terrain_stream/spawn/assemble.rs b/crates/benilla/src/terrain_stream/spawn/assemble.rs index 31df62db..9b0c5a5f 100644 --- a/crates/benilla/src/terrain_stream/spawn/assemble.rs +++ b/crates/benilla/src/terrain_stream/spawn/assemble.rs @@ -186,6 +186,7 @@ pub(crate) fn spawn_model_entities( sub.no_depth_write, sub.no_depth_test, sub.fog_policy, + sub.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs shade, batch_order, sub.uv_anim.as_ref(), @@ -226,6 +227,7 @@ pub(crate) fn spawn_model_entities( sub.no_depth_write, sub.no_depth_test, sub.fog_policy, + sub.env_map, // texture_unit_lookup > 2 ⇒ the runtime generates this batch's UVs shade, batch_order, sub.uv_anim.as_ref(), diff --git a/crates/benilla/src/terrain_stream/spawn/mod.rs b/crates/benilla/src/terrain_stream/spawn/mod.rs index 7d40b638..afab8b9b 100644 --- a/crates/benilla/src/terrain_stream/spawn/mod.rs +++ b/crates/benilla/src/terrain_stream/spawn/mod.rs @@ -601,7 +601,11 @@ pub(super) fn spawn_loaded_placements( ModelKind::Doodad, handle_label(&d.handle), unique_id, - format!("emitters: {} · WMO prop", m.emitters.len()), + format!( + "emitters: {} · WMO prop · {}", + m.emitters.len(), + d.light.inspector_label() + ), ); p.entities.extend(ents); d.spawned = true; diff --git a/crates/benilla/src/terrain_stream/spawn/prop_light.rs b/crates/benilla/src/terrain_stream/spawn/prop_light.rs index c27d4a67..d095a931 100644 --- a/crates/benilla/src/terrain_stream/spawn/prop_light.rs +++ b/crates/benilla/src/terrain_stream/spawn/prop_light.rs @@ -46,6 +46,33 @@ pub(crate) enum PropLight { }, } +impl PropLight { + /// The prop's lighting lane, as the mouseover inspector says it. A WMO prop that looks wrong is + /// almost always on the wrong *lane* or carrying an unbaked base — and neither is visible from + /// the model path alone, which is why Booty Bay's black entrance arch read as a texture bug for + /// as long as it did (decision 0969). `sky-lit` is the exterior lane; the interior lane prints + /// the MODD-colour words it actually commits, so a base of `#000000` names itself on hover. + pub(crate) fn inspector_label(&self) -> String { + let hex = |c: &[f32; 3]| { + let b = c.map(|v| (v * 255.0).round().clamp(0.0, 255.0) as u8); + format!("#{:02x}{:02x}{:02x}", b[0], b[1], b[2]) + }; + match self { + PropLight::Exterior => "sky-lit".into(), + PropLight::Interior { + ambient, + diffuse, + lights, + } => format!( + "interior amb {} dif {} · {} MOLR", + hex(ambient), + hex(diffuse), + lights.len() + ), + } + } +} + /// One MOLR-referenced light as the interior fold consumes it (world Bevy space, colour /// pre-multiplied by the authored intensity). Shared by the MODD prop spawn fold and the /// GameObject footprint lane ([`crate::interior`] via [`crate::wmo_portal`]'s verdict). diff --git a/crates/benilla/src/water_fx/mod.rs b/crates/benilla/src/water_fx/mod.rs index 5ada97d0..80c6f4c0 100644 --- a/crates/benilla/src/water_fx/mod.rs +++ b/crates/benilla/src/water_fx/mod.rs @@ -562,6 +562,9 @@ fn push_water_foam( blend: EffectBlend::Add, // The reference's foam render sets FOG off (VERIFIED `0x68fcc1`). fog: EffectFog::Off, + // The reference's foam render is its own additive path (`0x68fae0`), not the + // M2 batch state producer — no GL_LIGHTING on it. + lit: false, anchor: centroid / n as f32, bias: FOAM_BIAS, raster_bias: 0, diff --git a/crates/benilla/src/weather/precip/mod.rs b/crates/benilla/src/weather/precip/mod.rs index c2e353d1..23ec3c56 100644 --- a/crates/benilla/src/weather/precip/mod.rs +++ b/crates/benilla/src/weather/precip/mod.rs @@ -415,6 +415,9 @@ fn push_precip( texture: texture.id(), blend, fog, + // Rain/snow ride the reference's own weather render state (its verified Mod2x / + // forced-grey-fog trio), not the M2 batch state producer — no GL_LIGHTING on them. + lit: false, anchor: cam_pos, bias: 0.0, raster_bias: 0, diff --git a/crates/benilla/src/wmo_portal/audit/mod.rs b/crates/benilla/src/wmo_portal/audit/mod.rs index f11660f5..69eb2c0c 100644 --- a/crates/benilla/src/wmo_portal/audit/mod.rs +++ b/crates/benilla/src/wmo_portal/audit/mod.rs @@ -42,6 +42,7 @@ use bevy::math::{Affine3A, Mat4, Vec3}; use super::{compute_pvs, down_ray_seeds, floor_z_at, terrain_z_local, WmoModel, EXTERIOR}; mod light_probe; +mod pin; /// A building at its real spot on a real map: the model, plus the MODF placement to find (by its /// `uniqueId`) in `tile`, whose 3×3 tile block supplies the terrain the down-ray races. @@ -87,6 +88,15 @@ const BLACKSMITH: Site = Site { uid: 96048, }; +/// Undercity — B26's site: 200+ groups under Tirisfal, whose one reported doorway culls the room on +/// the far side from *both* sides of the arch (`.go xyz 1558.66 415.39 -62.16 0`). +const UNDERCITY: Site = Site { + wmo: r"world\wmo\lorderon\undercity\undercity.wmo", + map: "Azeroth", + tile: (31, 28), + uid: 239598, +}; + /// Eye height above a floor point for both the standing player and the seated camera samples. const EYE_HEIGHT: f32 = 1.7; diff --git a/crates/benilla/src/wmo_portal/audit/pin.rs b/crates/benilla/src/wmo_portal/audit/pin.rs new file mode 100644 index 00000000..f5096cb4 --- /dev/null +++ b/crates/benilla/src/wmo_portal/audit/pin.rs @@ -0,0 +1,165 @@ +//! **The pin probe** — a director's `.go xyz` / `/shot` report, replayed offline through the real +//! flood. The sibling sweeps in [`super`] ask "does the invariant hold anywhere in this building"; +//! this asks "what happened at exactly *that* spot", which is the question a bug report actually +//! poses. It reuses the harness's placed subjects and the same [`TraceLog`](super::super::probe) +//! recorder the in-client dump button writes, so there is no second flood to drift. + +use benilla_assets::coords::{bevy_to_wow, wow_to_bevy}; +use bevy::math::{Mat4, Vec3}; + +use super::{load_subject, Site, EXTERIOR, UNDERCITY}; + +/// **The pin probe** — a director's `.go xyz` / `/shot` report, replayed through the real flood. +/// +/// A bug report names a *place*, and until now turning that place into evidence meant launching the +/// client, walking there, and clicking the panel's dump button. This runs the same flood offline: give +/// it the WoW-world eye and look point the report carries and it prints the down-ray's seed evidence, +/// every portal hop's verdict, and the resulting visible set — the fixture a diagnosis starts from. +/// +/// ```text +/// WOW_PIN_EYE=1565.2,417.1,-56.2 WOW_PIN_LOOK=1517.5,406.7,-67.1 \ +/// cargo test -p benilla wmo_pin_probe -- --ignored --nocapture +/// ``` +/// +/// The subject defaults to [`UNDERCITY`] (B26); `WOW_PIN_WMO` + `WOW_PIN_UID` + `WOW_PIN_MAP` + +/// `WOW_PIN_TILE` retarget it at another placement. Output is Blizzard-derived — keep it out of the repo. +#[test] +#[ignore = "needs the local game data (WoW/Data); run with --ignored"] +fn wmo_pin_probe() { + fn xyz(var: &str, default: [f32; 3]) -> [f32; 3] { + let Ok(s) = std::env::var(var) else { + return default; + }; + let v: Vec = s.split(',').filter_map(|p| p.trim().parse().ok()).collect(); + <[f32; 3]>::try_from(&v[..]).unwrap_or_else(|_| panic!("{var} wants x,y,z; got {s:?}")) + } + let site = Site { + wmo: Box::leak( + std::env::var("WOW_PIN_WMO") + .unwrap_or_else(|_| UNDERCITY.wmo.to_string()) + .into_boxed_str(), + ), + map: Box::leak( + std::env::var("WOW_PIN_MAP") + .unwrap_or_else(|_| UNDERCITY.map.to_string()) + .into_boxed_str(), + ), + tile: std::env::var("WOW_PIN_TILE").map_or(UNDERCITY.tile, |s| { + let (a, b) = s.split_once(',').expect("WOW_PIN_TILE wants tx,ty"); + (a.trim().parse().unwrap(), b.trim().parse().unwrap()) + }), + uid: std::env::var("WOW_PIN_UID").map_or(UNDERCITY.uid, |s| s.trim().parse().unwrap()), + }; + let subject = load_subject(site.wmo, Some(&site)); + let placed = subject + .placed + .as_ref() + .expect("the pin probe needs a placement"); + + // The report's coordinates are WoW world space; the flood works in the placement's model space. + let eye_world_wow = xyz("WOW_PIN_EYE", [1565.2, 417.1, -56.2]); + let look_world_wow = xyz("WOW_PIN_LOOK", [1517.5, 406.7, -67.1]); + let to_local = + |wow: [f32; 3]| bevy_to_wow(placed.local_from_world.transform_point3(wow_to_bevy(wow))); + let eye = to_local(eye_world_wow); + let look = to_local(look_world_wow); + + // The camera exactly as the per-frame pass builds it: clip_from_world in Bevy world space, with + // the real placement transform, so portal projection sees what the runtime sees. + let eye_bevy = placed.world_from_local.transform_point3(wow_to_bevy(eye)); + let look_bevy = placed.world_from_local.transform_point3(wow_to_bevy(look)); + let clip = Mat4::perspective_rh(0.9, 16.0 / 9.0, 0.1, 1000.0) + * Mat4::look_at_rh(eye_bevy, look_bevy, Vec3::Y); + + let model = &subject.model; + println!( + "== pin probe: {} ({} groups, {} portals) ==\n\ + eye world ({:.2},{:.2},{:.2}) -> local ({:.2},{:.2},{:.2})\n\ + look world ({:.2},{:.2},{:.2}) -> local ({:.2},{:.2},{:.2})", + site.wmo, + model.group_nav.len(), + model.portal_infos.len(), + eye_world_wow[0], + eye_world_wow[1], + eye_world_wow[2], + eye[0], + eye[1], + eye[2], + look_world_wow[0], + look_world_wow[1], + look_world_wow[2], + look[0], + look[1], + look[2], + ); + + let terrain = subject.terrain_z(eye); + let mut log = crate::wmo_portal::probe::TraceLog::new(model, eye, terrain); + let pvs = crate::wmo_portal::compute_pvs_traced( + model, + eye, + terrain, + &clip, + &placed.world_from_local, + &mut log, + ); + // The trace's per-group preamble is 200+ lines on a city — keep only the hop verdicts and the + // seed evidence, which is what a "why is that room gone" question actually reads. + for line in log.text.lines() { + if !line.trim_start().starts_with('g') || line.contains("->") { + println!("{line}"); + } + } + + let vis: Vec = pvs + .iter() + .enumerate() + .filter(|(_, &v)| v) + .map(|(i, _)| i) + .collect(); + println!("visible: {} of {} groups {vis:?}", vis.len(), pvs.len()); + + // The portal graph of every group the flood reached. A flood that stops has either run out of + // edges or had them all rejected, and only the edge list tells the two apart. + println!("-- portal graph of the visible set --"); + for &gi in &vis { + let g = &model.group_nav[gi]; + let start = g.ref_start as usize; + let end = (start + g.ref_count as usize).min(model.portal_refs.len()); + let edges: Vec = model.portal_refs[start..end] + .iter() + .map(|r| format!("p{}->g{}(side {:+})", r.portal, r.group, r.side)) + .collect(); + println!( + " g{gi:02} flags {:#07x}{} bbox z[{:.1},{:.1}] refs[{}..+{}] {}", + g.flags, + if g.flags & EXTERIOR != 0 { " EXT" } else { "" }, + g.bbox_min[2], + g.bbox_max[2], + g.ref_start, + g.ref_count, + if edges.is_empty() { + "NO EDGES — the flood dead-ends here".to_string() + } else { + edges.join(" ") + } + ); + } + + // Which room is the director looking *at*? Report every group whose MOGI bbox contains the look + // point, with its verdict — the culled one there is the bug. + println!("-- groups containing the look point --"); + for (gi, g) in model.group_nav.iter().enumerate() { + let inside = (0..3).all(|k| look[k] >= g.bbox_min[k] && look[k] <= g.bbox_max[k]); + if inside { + println!( + " g{gi:02} flags {:#07x}{} refs[{}..+{}] -> {}", + g.flags, + if g.flags & EXTERIOR != 0 { " EXT" } else { "" }, + g.ref_start, + g.ref_count, + if pvs[gi] { "VISIBLE" } else { "CULLED" } + ); + } + } +}