outdoor lava flows, and Undercity gets its lost corridor back

- lava renders outside Blackrock at last: the mesher was dropping every open-world lava and slime surface, and 28 river mouths get their missing sea back in the same sweep
- Undercity's broken corridor returns, geometry and color both: a one-byte file overrun dropped the whole room, and a paint job one record short was being thrown away entire
- collision goes one-sided like the real client: walls stop you from the front and let you pass from behind
- the Booty Bay entrance is wood and rope again instead of a black cutout, glass picks up a real reflection, and models far from the map's center stop trembling

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-05 19:02:47 +07:00
co-authored by Claude Fable 5
parent a03cb5d5d2
commit 994be22f5c
73 changed files with 7050 additions and 3970 deletions
+125 -35
View File
@@ -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<LiquidVertex>,
/// 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<MclyChunk>,
pub alpha: Option<McalChunk>,
pub shadow: Option<McshChunk>,
pub liquid: Option<MclqChunk>,
/// 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<MclqChunk>,
}
/// MDDF — a placed M2 doodad (36 bytes).
@@ -408,7 +419,7 @@ fn read_mcnk(data: &[u8]) -> Result<McnkChunk> {
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<McnkChunk> {
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<MclqChunk> {
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<MclqChunk> {
/// 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<MclqChunk> {
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<MclqChunk> {
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");
}
}
@@ -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 -- <map> <x> <y> <z> [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 <map> <x> <y> <z> [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<Hit> = 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 `<stem>_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<Vec3> = 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<f32> {
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)
}
+1
View File
@@ -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
+6
View File
@@ -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.
+175 -29
View File
@@ -139,8 +139,11 @@ pub struct WmoModel {
pub doodad_base: Vec<DoodadBase>,
/// 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<Option<u16>>,
/// 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<u16>]) -> Vec<Option<u16>> {
let mut owner: Vec<Option<u16>> = 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<u16>]) -> Vec<Arc<[u1
refs.into_iter().map(Arc::from).collect()
}
/// Resolve every MODD placement's lighting base once, at load. Ownership and interior class follow
/// the faithful create path: a doodad belongs to the group(s) whose **MODR** references it (the
/// instantiate loop is per-group over MODR — never a spatial test), the owning group's
/// interior/exterior class (`MOGI flags & 0x48`) picks the lane, and the owning group's **MOLR**
/// list is its complete point-light candidate set. A MODD referenced by no group is never
/// instantiated by the reference at all — Exterior here (the harmless default if our spawner shows
/// it anyway).
/// Resolve every MODD placement's lighting base once, at load. Ownership follows the faithful
/// create path: a doodad belongs to the group(s) whose **MODR** references it (the instantiate loop
/// is per-group over MODR — never a spatial test), and the interior lane's point-light candidate
/// set is its owning group's **MOLR** list. A MODD referenced by no group is never instantiated by
/// the reference at all — Exterior here (the harmless default if our spawner shows it anyway).
///
/// `owner` is [`modr_owners`]' inversion, shared with the portal-cull key ([`WmoModel::doodad_owner`])
/// so the lighting lane and the cull can never disagree about which group a prop belongs to.
/// **EXTERIOR WINS.** The class is *not* the first referrer's: the reference caches one
/// `CMapDoodadDef` per (MODD index, **placement instance**) — `0x694e90` matches on
/// `[def+0xb4] == modd` *and* `[def+0xc8] == [placement+0x7c]+1` — so every group of a placed
/// building writes the lane of the *same* def, and the classify at `0x695aa0` makes exterior
/// absorbing: an interior group refuses to mark a def already flagged exterior
/// (`695ba0 test al,0x4; 695ba2 jne` → the exterior leg), while an exterior group **clears** the
/// interior bit and sets its own (`695bbd and ecx,0xfffd; 695bc3 or ecx,0x4`). So a prop any
/// exterior group names is sky-lit from the first frame that group is walked, and never goes back.
/// Taking the first referrer instead pinned Booty Bay's entrance arch — named by g22 (interior) and
/// g42 (exterior) — to the interior lane, whose whole base is the MODD colour: `#000000` there, i.e.
/// a pure black silhouette at the town gate (decision 0969; `benilla-extract darkpropscan` is the
/// corpus census).
///
/// `owner` is [`modr_owners`]' inversion and `refs` is [`modr_refs`]' — the latter shared with the
/// portal-cull key ([`WmoModel::doodad_groups`]), so the lane and the cull read the same relation.
fn resolve_doodad_bases(
doodads: &[WmoDoodad],
groups: &[WmoGroupInfo],
owner: &[Option<u16>],
refs: &[Arc<[u16]>],
group_light_refs: &[Vec<u16>],
) -> Vec<DoodadBase> {
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<Vec<u16>> = (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!(
@@ -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 -- <root.wmo> # 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 <root.wmo> | --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<String> = if arg == "--all" {
let mut v: Vec<String> = 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(())
}
@@ -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} [{}]",
@@ -308,6 +308,18 @@ enum Command {
/// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted.
prefix: Option<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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,
File diff suppressed because it is too large Load Diff
@@ -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<f32> {
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<String, u32> = 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<Option<benilla_formats::BillboardKind>> = 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<String> = 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<String, u32> = 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<String> = 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::<Vec<_>>()
.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<String> = m.iter().map(|(k, n)| format!("{k}:{n}")).collect();
v.sort();
v.join(" ")
};
let fx_tot = {
let mut v: Vec<String> = 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<String, u32> = 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<u16, u32> = 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("<root>");
*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::<Vec<_>>()
.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::<Vec<_>>()
.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<String> = Vec::new();
let mut model_hovers: Vec<f32> = 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::<Vec<_>>()
.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(())
}
@@ -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<String, u32> = 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<u16, Vec<u32>> = BTreeMap::new();
let mut light_refs: BTreeMap<u32, Vec<u16>> = 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::<f32>().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::<Vec<_>>()
.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\<sub>` 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<String>,
}
/// 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<String, FamilyStats> = BTreeMap::new();
// key -> (hit count, one example model).
let mut color_tally: BTreeMap<ColorKey, (u32, String)> = BTreeMap::new();
let mut hit_models: Vec<(String, Vec<M2Light>)> = 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<bool>| 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(())
}
@@ -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<u32> {
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<String, u32> = 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("<root>");
*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<String, u8> = 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<String, u32> = BTreeMap::new();
let mut by_sheet: BTreeMap<String, u32> = 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(())
}
@@ -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::<name>` 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<Vec<String>> {
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 `<stem>_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<Vec<String>> {
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())
}
@@ -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<String>,
recursion_model: Option<String>,
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<RecordExtras> {
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<String> {
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<String, Vec<(u32, String)>> {
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<String, Vec<(u32, String)>> = 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<String>,
}
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("<all>"),
);
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<String> = 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<String, Option<Vec<(String, bool)>>> = BTreeMap::new();
let mut fam_transparent: BTreeMap<String, u32> = BTreeMap::new();
let mut fam_opaque: BTreeMap<String, u32> = BTreeMap::new();
let mut rungs: BTreeMap<u32, u32> = BTreeMap::new();
let mut unresolved: Vec<String> = 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<String> = 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::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>()
.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<String, (u32, u32)> = BTreeMap::new();
let (mut scanned, mut with_fx, mut at_risk) = (0u32, 0u32, 0u32);
let mut rungs: BTreeMap<u32, u32> = 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(())
}
@@ -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<String, Vec<u32>> = 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<String, u32> = 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("<root>");
*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<String> = 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(())
}
@@ -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<String, u32> = 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<u16, usize> = BTreeMap::new();
for (i, a) in model.attachments.iter().enumerate() {
scan.entry(a.id).or_insert(i);
}
let lookup: BTreeMap<u16, usize> = (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<String> = 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<u32>,
vals: Vec<[f32; 4]>,
comps: usize,
}
impl RawBoneTrack {
fn read(b: &[u8], o: usize, comps: usize) -> Option<Self> {
let u32_at = |p: usize| -> Option<u32> {
b.get(p..p + 4)
.map(|s| u32::from_le_bytes(s.try_into().unwrap()))
};
let u16_at = |p: usize| -> Option<u16> {
b.get(p..p + 2)
.map(|s| u16::from_le_bytes(s.try_into().unwrap()))
};
let f32_at = |p: usize| -> Option<f32> {
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<usize> = (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::<f32>() < 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::<f32>()
.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::<f32>()
.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<String> = BTreeSet::new();
let mut gseq_restrict_models: BTreeSet<String> = 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<String> = 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<String, u64> = 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<usize> = (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<usize> = (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(())
}
@@ -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<u16, Vec<u32>> = 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<String> = 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::<Vec<_>>()
.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<String> {
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<u32> = 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<u32> = HashSet::new();
let mut m2_instances: HashMap<String, u32> = HashMap::new();
// WMO placements (MODF) — same dedup, by their own uniqueId.
let mut seen_wmo_ids: HashSet<u32> = HashSet::new();
let mut wmo_instances: HashMap<String, u32> = 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<String, M2AnimSummary> = 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} <parse failed> {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(())
}
+373 -88
View File
@@ -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<LiquidKind> {
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<bool>,
/// 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<f32>,
/// 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<LiquidMesh> {
// 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<LiquidMesh> {
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<f32> {
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<f32> = corners.iter().map(|&i| m.positions[i][0]).collect();
let ys: Vec<f32> = 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::<f32>() / 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<LiquidMesh> = 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<LiquidMesh> = 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);
}
@@ -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
+1
View File
@@ -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,
)
@@ -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<WmoBatchClass>,
/// 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,
}
}
}
+32 -15
View File
@@ -160,6 +160,10 @@ pub fn wmo_group_footprint_tris(group_bytes: &[u8]) -> Option<FootprintTris> {
/// 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<Vec<[u8; 4]>> {
let Ok(ParsedWmo::Group(group)) = parse_wmo(&mut Cursor::new(group_bytes)) else {
return None;
};
(group.vertex_colors.len() == group.vertex_positions.len()).then(|| {
group
.vertex_colors
.iter()
.map(|c| [c.b, c.g, c.r, c.a])
.collect()
})
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<Vec<Color>> {
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<Vec<Color>> {
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);
+11 -4
View File
@@ -36,6 +36,13 @@ pub fn load_wmo(chain: &mut Chain, raw_path: &str) -> Result<Vec<super::RenderSu
/// Find a top-level WMO chunk's data slice by its **on-disk (reversed) magic** — WMO/ADT store the
/// FourCC reversed (`MODN` → `NDOM`). Top-level root chunks are laid out flat from byte 0
/// (`[magic:4][size:u32 LE][data:size]`), so a linear walk locates any of them.
///
/// **The last chunk clamps to EOF; it never rejects the file.** The reference's 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", whose own worked example is the
/// file this rule exists for: `Undercity_144.wmo`'s MOGP declares one byte more than the file holds.
/// Abandoning the walk there cost that group its MOGP entirely — flags, portal-ref span, area, fog,
/// doodad and light refs — which dead-ended the portal flood at B26's doorway (decision 0972).
pub(crate) fn find_wmo_chunk<'a>(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]);
}
+87
View File
@@ -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<char>` 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<Vec<ParticleEmitterDef
geometry_model,
recursion_model,
blend: blend_of(bytes[e + 0x28]),
lit: lit_of(le_u32(bytes, e + 0x04), bytes[e + 0x28]),
texture,
tile_rows: tiles.0,
tile_cols: tiles.1,
@@ -824,6 +859,58 @@ pub fn parse_m2_particle_emitters(bytes: &[u8]) -> Result<Vec<ParticleEmitterDef
mod tests {
use super::*;
/// **File bit 0x1 is UNLIT, not "lit"** — the polarity this whole mechanism turns on, and the
/// one the community table gets backwards (wowdev: "0x1 = affected by lighting"; the binary
/// forces `GL_LIGHTING` OFF on that bit, wow-re `part-scene-multipliers.md` §1 @`0x70bb00`).
/// Inverting it would light exactly the wrong 95% of the corpus: every fire and spell effect
/// would go dim at night while the environment sheets stayed full-white cutouts — a change
/// that "works" on any single model you happen to check first.
#[test]
fn bit_one_is_the_unlit_flag_and_mod_blends_never_light() {
// The Zul'Gurub / Elwynn waterfall: bit 0x1 CLEAR, blend 2 (alpha) ⇒ the scene lights it.
assert!(
lit_of(0x0002, 2),
"an emitter that CLEARS 0x1 takes the light"
);
// The Orgrimmar bonfire's flame (0x29) and smoke (0x21): both SET 0x1 ⇒ both unlit, which
// is what keeps a campfire bright at night.
assert!(!lit_of(0x0029, 4), "0x1 set: unlit, whatever the blend");
assert!(!lit_of(0x0021, 2));
// `DAT_00811fa8` = {1,1,1,1,1,0,0}: the multiply modes light nothing even with 0x1 clear —
// a lit modulate would darken twice, once through the light and once through the blend.
for blend in 0u8..=4 {
assert!(lit_of(0x0000, blend), "blend {blend} lights");
}
assert!(!lit_of(0x0000, 5), "Mod never lights");
assert!(!lit_of(0x0000, 6), "Mod2x never lights");
// A blend byte past the table is not a panic and not a silent "unlit" — the reference
// indexes a 7-entry table it never overruns, and every such value already folds to
// `Opaque`, which lights.
assert!(lit_of(0x0000, 7));
}
/// The real corpus, at the emitter the director reported: the waterfall's ONE emitter clears
/// bit 0x1 and blends alpha, so it must come back LIT — the asset-side half of the foam fix.
#[test]
fn the_waterfall_emitter_is_lit() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let bytes = chain
.read_file(
"World\\Azeroth\\Elwynn\\PassiveDoodads\\Waterfall\\ElwynnTallWaterfall01.m2",
)
.expect("read ElwynnTallWaterfall01.m2");
let defs = parse_m2_particle_emitters(&bytes).expect("parse emitters");
assert_eq!(defs.len(), 1);
assert_eq!(defs[0].flags, 0x0002, "authored flag word");
assert_eq!(defs[0].blend, ParticleBlend::Alpha);
assert!(defs[0].lit, "the spray sheet is shaded by the world");
}
/// The repo root's `WoW/Data` (gitignored; the real-data tests skip when absent).
fn vanilla_data_dir() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
+13 -11
View File
@@ -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<crate::liquid::LiquidMesh>,
/// 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<crate::liquid::LiquidMesh>,
}
impl ChunkMesh {
@@ -479,12 +480,13 @@ pub fn adt_to_tile_mesh(adt_bytes: &[u8]) -> Result<TileMesh> {
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<TileMesh> {
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(),
}
}
@@ -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!(
@@ -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 `<repo>/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"
);
}
@@ -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<String> = 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<String> = 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)]
);
}
@@ -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<String> = 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<String> = 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"
);
}
+18 -1
View File
@@ -131,7 +131,9 @@ pub fn parse_m2(cursor: &mut Cursor<&[u8]>) -> Result<M2Format> {
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<M2Format> {
// 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<M2Format> {
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<M2Format> {
color_rgb_tracks,
transparency_tracks,
transparency_lookup,
texture_unit_lookup,
texture_transforms,
texture_transform_lookup,
global_sequences,
+28
View File
@@ -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<u16>,
/// 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<u16>,
/// The texture-transform records (see [`M2TextureTransform`]), selected via
/// `texture_transform_combo_index → texture_transform_lookup → here`.
pub texture_transforms: Vec<M2TextureTransform>,
@@ -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`.
+8 -2
View File
@@ -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)?,
@@ -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<f32>,
};
@vertex
@@ -91,6 +95,7 @@ fn vertex(v: Vertex) -> VertexOutput {
out.clip_position = view.clip_from_world * vec4<f32>(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<f32>(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<f32>(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<f32> {
// 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<f32>(0.0),
vec3<f32>(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,
+101 -19
View File
@@ -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<u32, 2048>,
// 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<u32, 2048>,
// 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<vec4<f32>, 2048>,
palettes: array<vec4<f32>>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> 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<u32>, weights: vec4<f32>) -> mat4x4<f32> {
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<f32>) -> mat3x3<f32> {
return mat3x3<f32>(x / det, y / det, z / det);
}
fn wow_skin_normals(world_from_local: mat4x4<f32>, normal: vec3<f32>) -> vec3<f32> {
// (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<f32>, normal: vec3<f32>) -> vec3<f32> {
return normalize(
inverse_transpose_3x3m(mat3x3<f32>(
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<f32>(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<f32>(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<f32>(vertex.position, 1.0)).xyz
+ (frame_origin - view.world_position);
out.world_position = vec4<f32>(p_cam + view.world_position, 1.0);
let view_rot = mat3x3<f32>(
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<f32>(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<f32>(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;
+3
View File
@@ -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,
+9 -1
View File
@@ -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(
|| ("<worn>".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)
},
)
},
);
+2
View File
@@ -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.
+640
View File
@@ -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<MoveHitData> {
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<MoveHitData> = 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<RayHitData> {
let end = origin + *dir * max_distance;
let swept = ColliderAabb {
min: origin.min(end),
max: origin.max(end),
};
let mut best: Option<RayHitData> = 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<Dir3> = 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<Vec3>,
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<Mesh>` 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::<Mesh>();
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}");
}
}
}
}
+158 -11
View File
@@ -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<DAffine3> = 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<DVec3>, Vec<DVec3>) = (Vec::new(), Vec::new());
let mut truths: Vec<DVec3> = 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::<f64>() / 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::<f64>() / 180.0,
rel_err.iter().map(|e| e.length()).sum::<f64>() / 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"
);
}
}
+14 -2
View File
@@ -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<GlobalTransform> = 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
@@ -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.
@@ -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,
+6
View File
@@ -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)
+3
View File
@@ -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,
+3 -2
View File
@@ -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
+1 -1
View File
@@ -94,7 +94,7 @@ fn liquid_scene(map: &str, wow: [f32; 3]) -> Option<LiquidScene> {
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,
+27 -2
View File
@@ -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,
+8 -3
View File
@@ -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
}
}
+17
View File
@@ -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<Image>,
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,
+8 -1
View File
@@ -56,8 +56,14 @@ fn census(
*last = time.elapsed_secs();
let mut per_cam: HashMap<Entity, usize> = 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<String> = 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;
+1
View File
@@ -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,
+1
View File
@@ -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,
+15
View File
@@ -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 {
+5
View File
@@ -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,
+13
View File
@@ -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,
+29 -21
View File
@@ -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,
},
);
}
}
}
+10 -8
View File
@@ -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(),
+15 -16
View File
@@ -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,
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+5
View File
@@ -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
+258 -51
View File
@@ -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<Vec<[f32; 4]>>,
/// Slot → base bone index. Slot 0 is the tag's "no rig" sentinel and never allocated.
table: Arc<Vec<u32>>,
/// 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<Vec<[f32; 4]>>,
/// 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<u32>,
free_slots: Vec<u16>,
@@ -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<bool> = 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<Ref<GlobalTransform>>,
) {
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<Vec<Mat4>> {
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<Vec<[f32; 4]>>,
table: Arc<Vec<u32>>,
origins: Arc<Vec<[f32; 4]>>,
dirty: Arc<Vec<(u32, u32, bool)>>,
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<RigPalettes>, mut out: ResMut<RigPaletteExtract>) {
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<RigPalettes>, mut out: ResMut<RigPa
}
out.rows = Arc::clone(&p.rows);
out.table = Arc::clone(&p.table);
out.origins = Arc::clone(&p.origins);
out.dirty = Arc::new(std::mem::take(&mut p.dirty));
out.table_generation = p.table_generation;
out.origin_generation = p.origin_generation;
out.origin_mirror_generation = p.origin_mirror_generation;
}
/// Extra `wow_light`-layout buffers that mirror the palette regions (the glue/portrait booths'
@@ -548,25 +684,48 @@ pub(crate) struct RigPaletteMirrors(
pub(crate) std::collections::HashMap<&'static str, bevy::render::render_resource::Buffer>,
);
/// 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<u64>,
dirty: u64,
origin: Option<u64>,
origin_mirror: Option<u64>,
}
/// 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<RenderQueue>,
shared: Option<Res<crate::lighting::SharedLightBuffer>>,
mirrors: Option<Res<RigPaletteMirrors>>,
data: Option<Res<RigPaletteExtract>>,
mut last: Local<(Option<u64>, u64)>,
mut last: Local<UploadedGenerations>,
) {
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<vec4<f32>, 2048>`.
assert_eq!(rig_origin_region_bytes(), (MAX_RIG_SLOTS * 16) as u64);
// One word per addressable slot — the shader declares `array<u32, 2048>` for both tables.
assert_eq!(
crate::instance_tint::region_bytes(),
+1
View File
@@ -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,
+1
View File
@@ -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,
+1 -1
View File
@@ -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,
@@ -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(),
@@ -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;
@@ -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).
+3
View File
@@ -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,
+3
View File
@@ -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,
@@ -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;
+165
View File
@@ -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<f32> = 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<usize> = 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<String> = 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" }
);
}
}
}