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:
+125
-35
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user