the map's invisible walls are read from the bytes at last
- the terrain's impassable-chunk flag is parsed and fenced the way the binary fences it — all four sides, floored, rising only — so movement stops exactly where 1.12.1 stops - a vertex normal authored as all zeros is legal content: the normalize is guarded, and the flat-black sleeves and skirts get their lighting back — a corpus sweep found 73 affected models nobody had filed - Caverns of Time's asteroid belts turn: a rigid bone needs no rig Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -162,6 +162,14 @@ pub struct MclqChunk {
|
||||
pub tile_flags: [u8; 64],
|
||||
}
|
||||
|
||||
/// MCNK header flag **bit 1** — the chunk a mover may not enter (the "impassable" band that walls
|
||||
/// Searing Gorge and the other mountain rims off from their neighbours).
|
||||
///
|
||||
/// The *producer* is VERIFIED per-instruction in wow-re (`system/terrain/scratch/adt-format.md`,
|
||||
/// the MCNK header walk: `hdr+0x00` bit1 → `chunk+0xc |= 0x40`); the *name* is wow-re's INFERRED
|
||||
/// label for that bit. Consuming it is decision 1266 / report B129.
|
||||
pub const MCNK_IMPASSABLE: u32 = 0x2;
|
||||
|
||||
/// The fields of the 128-byte MCNK header the renderer reads. `pred_tex`/`no_effect_doodad`/
|
||||
/// `unknown_8bytes` mirror wow-adt's (mis)split of the predominant-texture + noEffectDoodad region,
|
||||
/// which the consumer reconstructs — kept identical on purpose.
|
||||
@@ -180,6 +188,15 @@ pub struct McnkHeader {
|
||||
pub position: [f32; 3],
|
||||
}
|
||||
|
||||
impl McnkHeader {
|
||||
/// Is this chunk flagged [`MCNK_IMPASSABLE`]? The flag is a header bit, so its granularity is
|
||||
/// the whole 33.333 yd chunk; in the shipped data the set chunks form contiguous ribbons along
|
||||
/// zone rims (Searing Gorge's is 48 chunks of `Azeroth_33_44` alone).
|
||||
pub fn impassable(&self) -> bool {
|
||||
self.flags & MCNK_IMPASSABLE != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// One MCNK terrain chunk.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McnkChunk {
|
||||
|
||||
@@ -73,21 +73,27 @@ pub fn placement_rotation(rotation_deg: [f32; 3]) -> Quat {
|
||||
/// rotation by the WoW→Bevy basis (the meshes are baked there), and keep the uniform scale (a uniform
|
||||
/// scale is basis-invariant). This mirrors the conjugation in [`placement_rotation`].
|
||||
pub fn wmo_doodad_local(position: [f32; 3], orientation: [f32; 4], scale: f32) -> Transform {
|
||||
let q_wow = Quat::from_xyzw(
|
||||
orientation[0],
|
||||
orientation[1],
|
||||
orientation[2],
|
||||
orientation[3],
|
||||
);
|
||||
let to_bevy = wow_to_bevy_quat();
|
||||
Transform {
|
||||
translation: wow_to_bevy(position),
|
||||
// q and −q are the same rotation; normalize guards a denormalized stored quat.
|
||||
rotation: (to_bevy * q_wow * to_bevy.inverse()).normalize(),
|
||||
rotation: wow_rotation_to_bevy(orientation),
|
||||
scale: Vec3::splat(scale),
|
||||
}
|
||||
}
|
||||
|
||||
/// A rotation authored in **WoW model space** `(x, y, z, w)` → the equivalent rotation of a mesh
|
||||
/// already baked into Bevy space: the basis conjugation `B · q · B⁻¹`.
|
||||
///
|
||||
/// The conjugation (rather than a component shuffle) is the whole point — [`wow_to_bevy`] is a
|
||||
/// change of basis, and a rotation changes basis by similarity, not by permuting its parts. Every
|
||||
/// caller that has a stored WoW quaternion wants this: an MODD doodad's orientation
|
||||
/// ([`wmo_doodad_local`]) and an M2 bone track's rotation key alike.
|
||||
pub fn wow_rotation_to_bevy(q: [f32; 4]) -> Quat {
|
||||
let q_wow = Quat::from_xyzw(q[0], q[1], q[2], q[3]);
|
||||
let to_bevy = wow_to_bevy_quat();
|
||||
// q and −q are the same rotation; normalize guards a denormalized stored quat.
|
||||
(to_bevy * q_wow * to_bevy.inverse()).normalize()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -231,6 +231,29 @@ struct WowVsOut {
|
||||
// (the FFP never enables GL_LIGHT_MODEL_TWO_SIDE — no per-face flip). A selected light reaches every
|
||||
// vertex of its unit with no distance cutoff, exactly like a committed GL light — selection pops at
|
||||
// unit granularity (the authored vanilla behaviour), never mid-surface. Mirrored in terrain.wgsl.
|
||||
// **Zero-normal-safe normalize — the M2 corpus authors `(0,0,0)` vertex normals and the reference
|
||||
// draws them lit** (decision 1268). `Creature\QuirajProphet` (the AQ40 Qiraji Brainwasher)
|
||||
// authors them on 28 of its sleeve batch's 40 vertices; `Creature\TitanFemale` (Uldaman's Ironaya)
|
||||
// on 82. A plain `normalize()` turns that legal, shipped datum into NaN, and NaN poisons the whole
|
||||
// lighting chain: every SH dot is NaN, `clamp(NaN, 0, 1)` floors to 0, and the batch renders PURE
|
||||
// BLACK over its correct texture — the reported symptom, which only "came back" while the unit was
|
||||
// targeted because the highlight's `+64/255` emissive rides OUTSIDE the poisoned factor.
|
||||
//
|
||||
// The reference lands on the zero vector instead: its `Model2.bls` lit permutations normalize with
|
||||
// the era's `RSQ`/`MUL` pair (wow-re `models/scratch/model2-bls-vertex-sh.md` §2), where the
|
||||
// `0 × INF` product resolves to 0, so the order-2 SH quadratic form is evaluated at `N = 0` and
|
||||
// collapses to its **DC term** — the flat, direction-independent ambient the reference's own
|
||||
// screenshots show on exactly these surfaces. Passing the zero vector through reproduces that
|
||||
// through every lane here: the SH lobe keeps `c10.w + sun_dc`, `lit_nl` keeps its ambient, and
|
||||
// `point_light_sum`'s `max(N·L, 0)` is 0.
|
||||
// Guarded around the SAME `normalize` rather than an open-coded `v · inverseSqrt(l2)`: every
|
||||
// non-degenerate normal — i.e. the whole corpus outside these few batches — keeps its existing bits,
|
||||
// so the visual baselines cannot drift by a rounding step on account of this fix.
|
||||
fn wow_normalize(v: vec3<f32>) -> vec3<f32> {
|
||||
let l2 = dot(v, v);
|
||||
return select(vec3<f32>(0.0), normalize(v), l2 > 1e-12);
|
||||
}
|
||||
|
||||
fn point_light_sum(P: vec3<f32>, N: vec3<f32>, anchor: vec3<f32>) -> vec3<f32> {
|
||||
let count = u32(wow_light.point_count.x);
|
||||
var sel = array<u32, 3>(0u, 0u, 0u);
|
||||
@@ -363,7 +386,7 @@ fn inverse_transpose_3x3m(in: mat3x3<f32>) -> mat3x3<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(
|
||||
return wow_normalize(
|
||||
inverse_transpose_3x3m(mat3x3<f32>(
|
||||
frame_from_local[0].xyz,
|
||||
frame_from_local[1].xyz,
|
||||
@@ -682,7 +705,9 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
|
||||
// normal, WMO by its own (both genuinely fixed-function reference programs).
|
||||
let is_clutter = m.clutter_fade.w > 0.5;
|
||||
let L = -normalize(wow_light.light_sun.xyz);
|
||||
let n_m2 = normalize(pbr_input.world_normal);
|
||||
// `wow_normalize`, not `normalize`: the unskinned lane carries an authored `(0,0,0)` normal
|
||||
// through to here unchanged, and a NaN out of this line blacks the whole batch (see the helper).
|
||||
let n_m2 = wow_normalize(pbr_input.world_normal);
|
||||
// Bevy negates `world_normal` on the back faces of any DOUBLE-SIDED material — two-sided foliage
|
||||
// cross-quads (grass tufts, leaf cards) AND every WMO group face (our WMO loader marks all WMO
|
||||
// submeshes two-sided, models.rs). The reference has NO such per-face negation: WoW sets the GL
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Where the ADT's impassable chunks are around a pin:
|
||||
//! `cargo run -p benilla-formats --example impass_here -- <map> <x> <y> [radius_chunks]`
|
||||
//! e.g. `impass_here Azeroth -6601.98 -531.87 10`.
|
||||
//!
|
||||
//! An "invisible wall" report (B129) names a *place* — a `.go xyz` pin — and the first question is
|
||||
//! whether the wall is authored terrain at all: an MCNK with header flag bit 1 (`MCNK_IMPASSABLE`)
|
||||
//! set, or a WMO/M2 hull the reporter walked into. This prints
|
||||
//! the flag as a top-down chunk map — north up, west left, the in-game map's orientation, one
|
||||
//! glyph per 33.333 yd chunk — so the answer is a picture of the band rather than a guess, and the
|
||||
//! pin's own chunk is marked. `what_is_here` answers the other half (is there a model here).
|
||||
//!
|
||||
//! The map crosses tile seams on purpose: the flag is per chunk and a band runs across ADT
|
||||
//! boundaries, so a single-tile view is exactly how you miss the wall you are standing against —
|
||||
//! B129's pin sits 1.5 yd from its wall, with the flagged chunk in the *next* tile east.
|
||||
//!
|
||||
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use benilla_formats::{impassable_at, world_to_tile, TileMesh, CHUNK_SIZE, TILE_SIZE};
|
||||
|
||||
/// The map's NW origin: world x/y both run *down* from `+32 tiles` as the grid runs south/east.
|
||||
const MAP_CENTER: f32 = 32.0 * TILE_SIZE;
|
||||
|
||||
/// Global chunk index of a world coordinate on either axis — row from x (grows south), column from
|
||||
/// y (grows east). The same falling-from-`MAP_CENTER` addressing the client's per-chunk lookups use
|
||||
/// (wow-re's MCSH texel law, `0x69b350`).
|
||||
fn chunk_index(coord: f32) -> i32 {
|
||||
((MAP_CENTER - coord) / CHUNK_SIZE).floor() as i32
|
||||
}
|
||||
|
||||
/// The world coordinate of a global chunk index's centre (inverse of [`chunk_index`], +½ chunk).
|
||||
fn chunk_centre(index: i32) -> f32 {
|
||||
MAP_CENTER - (index as f32 + 0.5) * CHUNK_SIZE
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let usage = || anyhow::anyhow!("usage: impass_here <map> <x> <y> [radius_chunks]");
|
||||
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 radius: i32 = args.next().map_or(Ok(8), |r| r.parse())?;
|
||||
|
||||
let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)");
|
||||
let mut chain = benilla_formats::open_chain(&data)?;
|
||||
|
||||
let mut tiles: HashMap<(u32, u32), Option<TileMesh>> = HashMap::new();
|
||||
let mut flag_at = |chain: &mut _, sx: f32, sy: f32| -> Option<bool> {
|
||||
let (tx, ty) = world_to_tile(sx, sy);
|
||||
let tile = tiles
|
||||
.entry((tx, ty))
|
||||
.or_insert_with(|| benilla_formats::load_tile_mesh(chain, &map, tx, ty).ok());
|
||||
impassable_at(&tile.as_ref()?.chunks, [sx, sy, 0.0])
|
||||
};
|
||||
|
||||
let (pin_row, pin_col) = (chunk_index(x), chunk_index(y));
|
||||
let (ptx, pty) = world_to_tile(x, y);
|
||||
println!(
|
||||
"{map}: pin ({x}, {y}) is tile {ptx}_{pty}, chunk (col {}, row {}) of that tile",
|
||||
pin_col.rem_euclid(16),
|
||||
pin_row.rem_euclid(16),
|
||||
);
|
||||
println!("global chunk (row {pin_row}, col {pin_col}); {CHUNK_SIZE:.3} yd per glyph\n");
|
||||
|
||||
// North up (rows step south = falling x), west left (columns step east = falling y).
|
||||
println!(" '#' impassable · '.' passable · '@' the pin's chunk · ' ' no tile");
|
||||
print!(" ");
|
||||
for dc in -radius..=radius {
|
||||
// A tile seam every 16 chunks: mark it so a band's tile ownership is readable at a glance.
|
||||
print!(
|
||||
"{}",
|
||||
if (pin_col + dc).rem_euclid(16) == 0 {
|
||||
'|'
|
||||
} else {
|
||||
' '
|
||||
}
|
||||
);
|
||||
}
|
||||
println!();
|
||||
for dr in -radius..=radius {
|
||||
let row = pin_row + dr;
|
||||
print!("{:5} ", row);
|
||||
for dc in -radius..=radius {
|
||||
let col = pin_col + dc;
|
||||
let glyph = match flag_at(&mut chain, chunk_centre(row), chunk_centre(col)) {
|
||||
_ if (row, col) == (pin_row, pin_col) => '@',
|
||||
Some(true) => '#',
|
||||
Some(false) => '.',
|
||||
None => ' ',
|
||||
};
|
||||
print!("{glyph}");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
// The pin's own verdict, and how far the wall is in each cardinal direction — the number a
|
||||
// retest needs ("walk east 1.5 yd and you should stop").
|
||||
let pin_flag = flag_at(&mut chain, x, y);
|
||||
println!(
|
||||
"\npin chunk: {}",
|
||||
match pin_flag {
|
||||
Some(true) => "IMPASSABLE (the pin is inside the band)",
|
||||
Some(false) => "passable",
|
||||
None => "no tile loaded here",
|
||||
}
|
||||
);
|
||||
for (name, dx, dy) in [
|
||||
("north (+x)", 1.0, 0.0),
|
||||
("south (-x)", -1.0, 0.0),
|
||||
("west (+y)", 0.0, 1.0),
|
||||
("east (-y)", 0.0, -1.0),
|
||||
] {
|
||||
// Step chunk by chunk from the pin until the flag flips, then report the distance to that
|
||||
// chunk's *near boundary* — where a mover would actually be stopped.
|
||||
let hit = (1..=radius).find(|n| {
|
||||
let (sx, sy) = (
|
||||
x + dx * *n as f32 * CHUNK_SIZE,
|
||||
y + dy * *n as f32 * CHUNK_SIZE,
|
||||
);
|
||||
flag_at(&mut chain, sx, sy) == Some(true)
|
||||
});
|
||||
match hit {
|
||||
Some(n) => {
|
||||
let index =
|
||||
chunk_index(if dx != 0.0 { x } else { y }) + if dx + dy > 0.0 { -n } else { n };
|
||||
let near_edge = MAP_CENTER
|
||||
- (if dx + dy > 0.0 { index + 1 } else { index }) as f32 * CHUNK_SIZE;
|
||||
let d = (near_edge - if dx != 0.0 { x } else { y }).abs();
|
||||
println!(" {name}: impassable chunk {n} away, wall at {d:.2} yd");
|
||||
}
|
||||
None => println!(" {name}: clear for {radius} chunks"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -212,6 +212,18 @@ enum Command {
|
||||
/// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted.
|
||||
prefix: Option<String>,
|
||||
},
|
||||
/// Sweep every `.m2` (optionally under a path prefix) and census the batches carrying
|
||||
/// **degenerate authored vertex normals** — `(0,0,0)`, which the shipped corpus really does
|
||||
/// author (the AQ40 Qiraji Brainwasher's sleeves, Uldaman Ironaya's skirt). The reference
|
||||
/// consumes such a normal as the zero vector and its order-2 SH collapses to the flat DC term,
|
||||
/// so the surface draws lit; a renderer that `normalize()`s it gets NaN, `clamp(NaN)` floors
|
||||
/// the whole lighting factor to 0, and the batch renders PURE BLACK over its correct texture.
|
||||
/// The population instrument for that class (decision 1268, bug B134) — `ALL` marks a batch
|
||||
/// where every vertex is degenerate, and the tail names the worst-hit models.
|
||||
Normalscan {
|
||||
/// Internal-path prefix filter (e.g. `creature`), case-insensitive; all models if omitted.
|
||||
prefix: Option<String>,
|
||||
},
|
||||
/// Sweep every `.m2` (optionally under a path prefix) and measure how far its authored header
|
||||
/// bounding box — the model's **all-animation** vertex extent, and the box the reference
|
||||
/// derives its doodad cull sphere from (`rec+0x5c`/`rec+0x68`) — reaches past its **bind-pose**
|
||||
@@ -671,6 +683,7 @@ fn main() -> Result<()> {
|
||||
Command::Bbscan { prefix } => scan::bbscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Bbfacescan { prefix } => scan::bbfacescan(&mut chain, prefix.as_deref())?,
|
||||
Command::Groundscan { prefix } => scan::groundscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Normalscan { prefix } => scan::normalscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Animboundscan { prefix } => scan::animboundscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Geosetscan { prefix } => scan::geosetscan(&mut chain, prefix.as_deref())?,
|
||||
Command::Alphascan { prefix } => scan::alphascan(&mut chain, prefix.as_deref())?,
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//! 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.
|
||||
//! single-triangle strays (`geosetscan`), flat ground-plane quads (`groundscan`), and degenerate
|
||||
//! authored vertex normals (`normalscan`). What MATERIAL a batch carries is [`super::material`]'s
|
||||
//! question.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
@@ -614,3 +615,68 @@ pub fn animboundscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `normalscan` — census the batches carrying **degenerate authored vertex normals** (`(0,0,0)`).
|
||||
///
|
||||
/// The shipped corpus authors them, and the reference draws those surfaces lit: its `Model2.bls`
|
||||
/// vertex program consumes the normal as the zero vector, so the order-2 SH quadratic form
|
||||
/// collapses to its DC term (wow-re `models/scratch/model2-bls-vertex-sh.md` §2). A renderer that
|
||||
/// `normalize()`s the same datum gets NaN, `clamp(NaN, 0, 1)` floors the lighting factor to 0, and
|
||||
/// the batch renders **pure black over its correct texture** — bug B134's Qiraji Brainwasher
|
||||
/// sleeves and Ironaya skirt, and the reason the shader's normalize is guarded (decision 1268).
|
||||
///
|
||||
/// This is the population instrument for that class: how many models are on it, how much of each
|
||||
/// batch is degenerate, and which models are worst hit. `ALL` marks a batch with no usable normal
|
||||
/// at all — the one that renders as a solid black shape rather than a shaded gradient.
|
||||
pub fn normalscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
|
||||
let names = super::m2_names(chain, prefix)?;
|
||||
// (degenerate verts, total verts, batches touched, all-degenerate batches, model)
|
||||
let mut rows: Vec<(usize, usize, usize, usize, String)> = Vec::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;
|
||||
};
|
||||
let (mut bad, mut total, mut touched, mut all_bad) = (0usize, 0usize, 0usize, 0usize);
|
||||
for s in &subs {
|
||||
let n = s
|
||||
.normals
|
||||
.iter()
|
||||
.filter(|v| v[0] * v[0] + v[1] * v[1] + v[2] * v[2] <= 1e-8)
|
||||
.count();
|
||||
total += s.normals.len();
|
||||
if n > 0 {
|
||||
bad += n;
|
||||
touched += 1;
|
||||
all_bad += usize::from(n == s.normals.len());
|
||||
}
|
||||
}
|
||||
if bad > 0 {
|
||||
rows.push((bad, total, touched, all_bad, name));
|
||||
}
|
||||
}
|
||||
rows.sort_by_key(|r| std::cmp::Reverse(r.0));
|
||||
let batches: usize = rows.iter().map(|r| r.2).sum();
|
||||
let all_batches: usize = rows.iter().map(|r| r.3).sum();
|
||||
let verts: usize = rows.iter().map(|r| r.0).sum();
|
||||
println!("normalscan — degenerate (0,0,0) authored vertex normals");
|
||||
println!(" models scanned: {scanned}");
|
||||
println!(" models with any: {}", rows.len());
|
||||
println!(" batches touched: {batches}");
|
||||
println!(" batches with NO usable normal: {all_batches}");
|
||||
println!(" degenerate vertices: {verts}");
|
||||
println!();
|
||||
println!(
|
||||
"{:>7} {:>7} {:>7} {:>4} MODEL",
|
||||
"BAD", "OF", "BATCHES", "ALL"
|
||||
);
|
||||
for (bad, total, touched, all_bad, name) in rows.iter().take(60) {
|
||||
println!("{bad:7} {total:7} {touched:7} {all_bad:4} {name}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ mod sequence;
|
||||
mod skeleton;
|
||||
mod world;
|
||||
|
||||
pub use geometry::{animboundscan, bbfacescan, bbscan, geosetscan, groundscan};
|
||||
pub use geometry::{animboundscan, bbfacescan, bbscan, geosetscan, groundscan, normalscan};
|
||||
pub use lighting::{darkpropscan, m2lightscan, shadeat};
|
||||
pub use material::{alphascan, blendscan, envmapscan, texmodescan, uvwrapscan};
|
||||
pub use particles::{
|
||||
|
||||
@@ -182,10 +182,10 @@ mod models;
|
||||
pub use models::{
|
||||
accumulate_wmo_group_camera_collision, accumulate_wmo_group_camera_only_collision,
|
||||
accumulate_wmo_group_collision, hand_grip_finger_poses, load_m2_animation_summary,
|
||||
load_m2_bounds, load_m2_collision_hull, load_m2_mesh, load_m2_mesh_skinned, load_object_model,
|
||||
load_wmo, load_wmo_collision_tris, m2_owner_reach, m2_ribbon_emitter_count,
|
||||
m2_texture_transform_count, non_separable_billboard_bones, owner_last_rung,
|
||||
owner_last_rung_bucket, parse_m2_animation_lookup, parse_m2_animation_summary,
|
||||
load_m2_bone_spins, load_m2_bounds, load_m2_collision_hull, load_m2_mesh, load_m2_mesh_skinned,
|
||||
load_object_model, load_wmo, load_wmo_collision_tris, m2_bone_spins, m2_owner_reach,
|
||||
m2_ribbon_emitter_count, m2_texture_transform_count, non_separable_billboard_bones,
|
||||
owner_last_rung, owner_last_rung_bucket, parse_m2_animation_lookup, parse_m2_animation_summary,
|
||||
parse_m2_animations, parse_m2_attachments, parse_m2_bounds, parse_m2_camera,
|
||||
parse_m2_cch_marker, parse_m2_collision_hull, parse_m2_event_markers,
|
||||
parse_m2_global_sequence_bones, parse_m2_lights, parse_m2_playable_animation_lookup,
|
||||
@@ -194,7 +194,7 @@ pub use models::{
|
||||
wmo_group_doodad_refs, wmo_group_fixed_colors, wmo_group_footprint_tris, wmo_group_header,
|
||||
wmo_group_light_refs, wmo_group_liquid_mesh, wmo_group_raw_colors, wmo_group_submeshes,
|
||||
wmo_root_id, AlphaAnim, AlphaSeq, AnimEvent, Billboard, BillboardKind, BoneKeys, BoneScaleAnim,
|
||||
CharSkinSlot, CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris,
|
||||
BoneSpin, CharSkinSlot, CollisionMesh, EmitterBoneLink, EventMarker, FogPolicy, FootprintTris,
|
||||
GlobalSeqBone, GlobalSeqChannel, GroundQuad, M2AnimSummary, M2Attachment, M2Bounds, M2Light,
|
||||
M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis, PlayableAnim,
|
||||
RenderSubmesh, RgbAnim, ScalarAnim, Skeleton, SkeletonBone, StringAnchors, UvAnim,
|
||||
@@ -203,9 +203,10 @@ pub use models::{
|
||||
};
|
||||
mod terrain;
|
||||
pub use terrain::{
|
||||
adt_to_tile_mesh, area_id_at, find_tile_near, ground_effect_at, load_tile_mesh,
|
||||
adt_to_tile_mesh, area_id_at, find_tile_near, ground_effect_at, impassable_at, load_tile_mesh,
|
||||
load_tiles_around, mcsh_shadowed_at, terrain_height_at, triangle_z_at, ChunkMesh, Doodad,
|
||||
MapTiles, TileMesh, WmoInstance, ALPHA_MAP_SIZE, SHADOW_MAP_SIZE, STORMWIND_XY, TILE_SIZE,
|
||||
MapTiles, TileMesh, WmoInstance, ALPHA_MAP_SIZE, CHUNK_SIZE, SHADOW_MAP_SIZE, STORMWIND_XY,
|
||||
TILE_SIZE,
|
||||
};
|
||||
mod wdl;
|
||||
/// World (x, y) ↔ ADT tile `(col, row)` — the same mapping the streamer uses to pick tiles
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
//! every sequence's per-bone keyframe tracks, which the skinned-entity path turns into Bevy clips.
|
||||
//! Split out of the model parser as its own concern.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
|
||||
use anyhow::Result;
|
||||
use benilla_m2::parse_m2;
|
||||
|
||||
use super::{le_f32, le_u16, le_u32};
|
||||
use crate::BoneSpin;
|
||||
|
||||
/// One bone of a model's rest skeleton (decision 0019): its parent bone index (`-1` = root) and pivot
|
||||
/// point in **raw WoW model space** (the render boundary maps it to Bevy space). Vanilla M2 has no
|
||||
@@ -281,6 +283,60 @@ pub fn parse_m2_animation_lookup(bytes: &[u8]) -> Result<Vec<u16>> {
|
||||
Ok(format.model().animation_lookup.clone())
|
||||
}
|
||||
|
||||
/// Every bone of `bytes` that **spins rigidly** in the sequence a loader arm plays — see
|
||||
/// [`BoneSpin`] for what that means and why each clause of the predicate is there. Keyed by M2 bone
|
||||
/// index, so a caller can look up the sole bone its batch is weighted to; empty for a model with no
|
||||
/// such bone, which is almost every model.
|
||||
///
|
||||
/// **The sequence is `anim_id == 0`** — the idle the reference arms by default (VERIFIED
|
||||
/// wow-5875-re, and the doodad host's load arm: bone 0, animation id 0, wow-re
|
||||
/// `doodad-anim-host.md` §4a). Selection is by id, never by record index ([`ModelAnimation`]'s own
|
||||
/// rule). Whether a *WMO skybox* is armed by that same path is NOT byte-pinned — both shipped
|
||||
/// skyboxes author exactly one sequence, so there is nothing else it could be playing, but a model
|
||||
/// with several would need the arming verified before this picked among them.
|
||||
pub fn m2_bone_spins(bytes: &[u8]) -> HashMap<u16, BoneSpin> {
|
||||
let mut out = HashMap::new();
|
||||
let Ok(skeleton) = parse_m2_skeleton(bytes) else {
|
||||
return out;
|
||||
};
|
||||
let anims = parse_m2_animations(bytes);
|
||||
let Some(seq) = anims.iter().find(|a| a.anim_id == 0) else {
|
||||
return out;
|
||||
};
|
||||
let (bone_count, bone_ofs) = (le_u32(bytes, 0x34) as usize, le_u32(bytes, 0x38) as usize);
|
||||
for keys in &seq.bones {
|
||||
let idx = keys.bone as usize;
|
||||
// Rotation only, and enough keys to move: a translation or scale track on the same bone is
|
||||
// still rigid but is not a spin about the pivot, and one key is a pose, not an animation.
|
||||
if keys.rotation.len() < 2 || !keys.translation.is_empty() || !keys.scale.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Parentless: nothing above it can compose another motion in (see [`BoneSpin`]).
|
||||
let Some(bone) = skeleton.bones.get(idx).filter(|b| b.parent < 0) else {
|
||||
continue;
|
||||
};
|
||||
// The rotation track's own `interp_type` word — bone record stride `0x6c`, rotation
|
||||
// `M2Track` at `+0x28`, `interp_type` at its `+0x00` (the same offsets `parse_m2_animations`
|
||||
// reads the track through, where `read_channel_track`'s `step` is `interp_type == 0`).
|
||||
// Bounds-checked rather than leaning on `parse_m2_animations` having already validated the
|
||||
// record: `le_u16` panics past the end, and the two readers' guards must not be coupled.
|
||||
let interp = idx < bone_count
|
||||
&& bone_ofs
|
||||
.checked_add(idx * 0x6c + 0x28)
|
||||
.is_some_and(|t| t + 2 <= bytes.len() && le_u16(bytes, t) != 0);
|
||||
out.insert(
|
||||
keys.bone,
|
||||
BoneSpin {
|
||||
pivot: bone.pivot,
|
||||
duration: seq.duration,
|
||||
interp,
|
||||
keys: keys.rotation.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One bone's keyframe tracks for an animation sequence (decision 0019), in **raw WoW model space**:
|
||||
/// translation/scale are `[f32;3]`, rotation is an uncompressed quaternion `[x,y,z,w]` (vanilla v256).
|
||||
/// Times are **seconds**, rebased to the sequence start so each clip runs `0..duration`. Only the
|
||||
|
||||
@@ -228,6 +228,20 @@ pub fn load_m2_mesh_skinned(
|
||||
parse_m2_render_submeshes(&bytes, &dir, skins).with_context(|| format!("parsing M2 {path}"))
|
||||
}
|
||||
|
||||
/// [`crate::m2_bone_spins`] for a model in the patch chain — the shape [`load_m2_mesh`]'s callers
|
||||
/// already use, so a lane wanting both reads the file through the same normalisation rather than
|
||||
/// threading bytes across the boundary.
|
||||
pub fn load_m2_bone_spins(
|
||||
chain: &mut Chain,
|
||||
raw_path: &str,
|
||||
) -> Result<std::collections::HashMap<u16, crate::BoneSpin>> {
|
||||
let path = model_path(raw_path);
|
||||
let bytes = chain
|
||||
.read_file(&path)
|
||||
.with_context(|| format!("reading M2 {path}"))?;
|
||||
Ok(crate::m2_bone_spins(&bytes))
|
||||
}
|
||||
|
||||
/// The model's billboard bones that are **not** rigidly separable — the population the card split
|
||||
/// declines to claim, read through the renderer's own predicate so the census can't drift from it
|
||||
/// (`benilla-extract bbscan`'s `SEAM` column). Empty for a model with no billboard bones, and for
|
||||
|
||||
@@ -196,6 +196,114 @@ impl BoneScaleAnim {
|
||||
}
|
||||
}
|
||||
|
||||
/// A bone that **spins rigidly**: a parentless bone whose armed sequence keys ROTATION only, so every
|
||||
/// vertex weighted wholly to it moves as a rigid body about the pivot — `T(pivot) · R(t) · T(−pivot)`
|
||||
/// — with no skinning palette and no joint hierarchy involved.
|
||||
///
|
||||
/// This is the same "rigidly separable bone ⇒ per-batch transform" reading the billboard card split
|
||||
/// already makes ([`Billboard`], and `separable_billboard_bones`' two conditions), applied to an
|
||||
/// authored rotation instead of a camera-facing one. It is a **narrow** claim on purpose, and every
|
||||
/// clause is load-bearing:
|
||||
///
|
||||
/// - **Parentless.** With a parent, the bone's world motion is its parent's chain composed with its
|
||||
/// own, which is not a single rigid transform unless the whole chain is static. A parented bone is
|
||||
/// simply not collected — it holds its bind pose, the conservative failure.
|
||||
/// - **Rotation only.** A translation or scale track on the same bone would still be rigid, but the
|
||||
/// pivot-conjugated form below would be wrong, so those bones are not collected either.
|
||||
/// - **Wholly-weighted geometry** is the caller's half: a vertex split between this bone and another
|
||||
/// is half a rigid body's worth of motion, which no rigid transform can produce.
|
||||
///
|
||||
/// The population this exists for: `CavernsOfTimeSky.m2`'s three asteroid belts (bones 1/2/3, each
|
||||
/// parentless, each keying rotation alone over one 66.667 s loop — 25°, 90° and a full 360°), whose
|
||||
/// four batches are each `[bone@1.00]` across every vertex (`benilla-extract m2batch`).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BoneSpin {
|
||||
/// The bone's pivot, model space (WoW axes) — the rotation centre.
|
||||
pub pivot: [f32; 3],
|
||||
/// The armed sequence's duration (**seconds**). Sampling wraps at this period.
|
||||
pub duration: f32,
|
||||
/// Linear interpolation between keys; `false` = step (the track's `interp_type == 0`). Read from
|
||||
/// the rotation track's own header word because [`BoneKeys`] — this collector's source for the
|
||||
/// keys themselves — deliberately drops it, and a step track lerped is a real divergence
|
||||
/// (`benilla-extract bonescan`'s recorded residual). There is no reason to reproduce that here.
|
||||
pub interp: bool,
|
||||
/// `(seconds, [x, y, z, w])` keyframes, rebased to the sequence start, time-ascending. The
|
||||
/// quaternion is raw WoW model space — conjugate it into the render basis at the use site.
|
||||
pub keys: Vec<(f32, [f32; 4])>,
|
||||
}
|
||||
|
||||
impl BoneSpin {
|
||||
/// Sample the rotation at `time` seconds (wrapped into `[0, duration)`), **slerping** between the
|
||||
/// bracketing keys — the rotation analogue of [`BoneScaleAnim::sample`], with the same search,
|
||||
/// the same step leg, and the same clamp past the final key.
|
||||
///
|
||||
/// Clamping rather than wrapping around to key 0 is what the track actually says: an M2 track's
|
||||
/// keys live inside the sequence band and the client interpolates within it, so a loop whose last
|
||||
/// key is not its first snaps at the wrap. That is authored — `CavernsOfTimeSky` bone 1 runs 0° →
|
||||
/// 25° and snaps back — and inventing a wrap-around segment would be us animating, not the file.
|
||||
pub fn sample(&self, time: f32) -> [f32; 4] {
|
||||
const IDENTITY: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
|
||||
let n = self.keys.len();
|
||||
if n == 0 {
|
||||
return IDENTITY;
|
||||
}
|
||||
// `rem_euclid` rather than `%`: a negative cursor (a clock read before the anchor) must land
|
||||
// inside the loop, not mirror onto its first key.
|
||||
let t = if self.duration > 0.0 {
|
||||
time.rem_euclid(self.duration)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if t <= self.keys[0].0 {
|
||||
return self.keys[0].1;
|
||||
}
|
||||
let mut k = 0;
|
||||
while k + 1 < n && self.keys[k + 1].0 <= t {
|
||||
k += 1;
|
||||
}
|
||||
if !self.interp || k + 1 >= n {
|
||||
return self.keys[k].1; // step, or clamp past the final key
|
||||
}
|
||||
let (t0, q0) = self.keys[k];
|
||||
let (t1, q1) = self.keys[k + 1];
|
||||
let frac = if t1 > t0 { (t - t0) / (t1 - t0) } else { 0.0 };
|
||||
slerp(q0, q1, frac)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shortest-arc spherical interpolation of two quaternions, on plain arrays.
|
||||
///
|
||||
/// Hand-rolled because `benilla-formats` deliberately carries no math crate — the render basis (and
|
||||
/// with it `glam`) starts at `benilla-assets`. Two guards earn their place: negating `b` when the dot
|
||||
/// is negative takes the SHORT way round (without it a 360° track's second half spins backwards), and
|
||||
/// falling back to a normalised lerp for near-parallel keys avoids `acos`'s vanishing `sin` denominator.
|
||||
fn slerp(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
|
||||
let mut dot = (0..4).map(|i| a[i] * b[i]).sum::<f32>();
|
||||
let mut b = b;
|
||||
if dot < 0.0 {
|
||||
b = [-b[0], -b[1], -b[2], -b[3]];
|
||||
dot = -dot;
|
||||
}
|
||||
let (w0, w1) = if dot > 0.9995 {
|
||||
(1.0 - t, t) // near-parallel: lerp, then renormalise below
|
||||
} else {
|
||||
let theta = dot.clamp(-1.0, 1.0).acos();
|
||||
let sin = theta.sin();
|
||||
(((1.0 - t) * theta).sin() / sin, (t * theta).sin() / sin)
|
||||
};
|
||||
let mut q = [0.0f32; 4];
|
||||
for i in 0..4 {
|
||||
q[i] = a[i] * w0 + b[i] * w1;
|
||||
}
|
||||
let len = q.iter().map(|c| c * c).sum::<f32>().sqrt();
|
||||
if len > 0.0 {
|
||||
for c in &mut q {
|
||||
*c /= len;
|
||||
}
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
/// A batch attached to an M2 **billboard bone**: the real client re-orients the bone to the camera
|
||||
/// every frame (so the card is visible from all sides and tracks the viewer). `pivot` is the bone's
|
||||
/// pivot in model space (WoW axes) — the point the card rotates about.
|
||||
|
||||
@@ -30,8 +30,9 @@ pub const SHADOW_MAP_SIZE: u32 = 64;
|
||||
|
||||
/// Yards per ADT tile (1/64 of a map edge).
|
||||
pub const TILE_SIZE: f32 = 533.333_3;
|
||||
/// Yards per MCNK chunk (16×16 chunks per tile).
|
||||
const CHUNK_SIZE: f32 = TILE_SIZE / 16.0;
|
||||
/// Yards per MCNK chunk (16×16 chunks per tile) — also the grid the impassable-chunk wall runs on
|
||||
/// (decision 1266), which is why it is public.
|
||||
pub const CHUNK_SIZE: f32 = TILE_SIZE / 16.0;
|
||||
/// Yards between adjacent outer-grid vertices (9×9 grid spans one chunk).
|
||||
/// `pub(crate)` so the liquid mesher shares the exact same lattice spacing.
|
||||
pub(crate) const UNIT_SIZE: f32 = CHUNK_SIZE / 8.0;
|
||||
@@ -100,6 +101,11 @@ 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,
|
||||
/// MCNK header flag bit 1 ([`benilla_adt::MCNK_IMPASSABLE`]): this chunk is authored
|
||||
/// **impassable** — the ADT-level invisible wall the reference stops a mover at (report B129,
|
||||
/// decision 1266). A header bit, so it covers the whole 33.333 yd chunk: the wall runs the
|
||||
/// chunk grid, not the terrain relief.
|
||||
pub impassable: bool,
|
||||
/// 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).
|
||||
@@ -162,6 +168,17 @@ impl ChunkMesh {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the **raw WoW-space** position inside this chunk's footprint, and is the chunk flagged
|
||||
/// [`benilla_adt::MCNK_IMPASSABLE`]? `None` = outside (caller tries the next chunk). Same
|
||||
/// containment math as [`Self::area_at`] — and the same reason it is a *footprint* test and not
|
||||
/// a height test: the flag has no z extent, so the answer is the same at every altitude.
|
||||
pub fn impassable_at(&self, wow: [f32; 3]) -> Option<bool> {
|
||||
let nw = *self.positions.first()?;
|
||||
let south = (nw[0] - wow[0]) / TILE_SIZE * 16.0;
|
||||
let east = (nw[1] - wow[1]) / TILE_SIZE * 16.0;
|
||||
((0.0..=1.0).contains(&south) && (0.0..=1.0).contains(&east)).then_some(self.impassable)
|
||||
}
|
||||
|
||||
/// The terrain surface height (WoW `z`) under a **raw WoW-space** column, by exact interpolation
|
||||
/// of the covering center-fan triangle. `None` when the column lies outside this chunk's footprint
|
||||
/// (caller tries the next chunk) **or falls in an MCNK hole**.
|
||||
@@ -217,6 +234,14 @@ pub fn area_id_at(chunks: &[ChunkMesh], wow: [f32; 3]) -> Option<u32> {
|
||||
chunks.iter().find_map(|c| c.area_at(wow))
|
||||
}
|
||||
|
||||
/// Is a raw WoW-space position over an **impassable** MCNK, given **one tile's** chunks? `None`
|
||||
/// when the position lies outside every chunk (a different tile — the same containment contract as
|
||||
/// [`area_id_at`]), which a caller must read as "unknown", never as "passable": the chunk on the
|
||||
/// far side of a tile seam is exactly where B129's wall is authored.
|
||||
pub fn impassable_at(chunks: &[ChunkMesh], wow: [f32; 3]) -> Option<bool> {
|
||||
chunks.iter().find_map(|c| c.impassable_at(wow))
|
||||
}
|
||||
|
||||
/// The `GroundEffectTexture` id under a raw WoW-space position, given **one tile's** chunks;
|
||||
/// `None` when outside every chunk or when the containing cell's layer has no effect (footstep
|
||||
/// consumers fall back to the Dirt default either way).
|
||||
@@ -504,6 +529,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,
|
||||
impassable: h.impassable(),
|
||||
liquids,
|
||||
});
|
||||
}
|
||||
@@ -730,6 +756,7 @@ mod tests {
|
||||
index_x: 0,
|
||||
index_y: 0,
|
||||
area_id: 0,
|
||||
impassable: false,
|
||||
liquids: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -816,4 +843,52 @@ mod tests {
|
||||
[0.0, 180.0, 0.0]
|
||||
);
|
||||
}
|
||||
|
||||
/// B129's pin, from the shipped bytes: the reference stops you 1.46 yd east of
|
||||
/// `.go xyz -6601.98 -531.87 335.60 0`, and this is the wall — the MCNK immediately east is
|
||||
/// flagged [`benilla_adt::MCNK_IMPASSABLE`] while the one under the pin is not.
|
||||
///
|
||||
/// The numbers are the point. A regression that dropped the flag, or read the neighbouring bit,
|
||||
/// would leave the pin's own chunk reading the same and only the *other* side of the seam wrong
|
||||
/// — so the test pins both sides, the seam they straddle, and the band's size. Skips without
|
||||
/// client data.
|
||||
#[test]
|
||||
fn the_b129_pin_stands_one_chunk_west_of_an_impassable_band() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
|
||||
// The pin, and a step east across the chunk boundary at y = -533.333 (one tile seam too:
|
||||
// the flagged chunk is column 0 of Azeroth_33_44, which is why a single-tile view misses it).
|
||||
let pin = [-6601.98, -531.87, 335.60];
|
||||
let east = [-6601.98, -535.0, 335.60];
|
||||
assert_eq!(crate::world_to_tile(pin[0], pin[1]), (32, 44));
|
||||
assert_eq!(crate::world_to_tile(east[0], east[1]), (33, 44));
|
||||
|
||||
let here = load_tile_mesh(&mut chain, "Azeroth", 32, 44).expect("Azeroth_32_44.adt");
|
||||
let next = load_tile_mesh(&mut chain, "Azeroth", 33, 44).expect("Azeroth_33_44.adt");
|
||||
|
||||
assert_eq!(
|
||||
impassable_at(&here.chunks, pin),
|
||||
Some(false),
|
||||
"the pin's own chunk is walkable — the reporter was standing on it"
|
||||
);
|
||||
assert_eq!(
|
||||
impassable_at(&next.chunks, east),
|
||||
Some(true),
|
||||
"the chunk 1.46 yd east is the wall"
|
||||
);
|
||||
// The containment contract: a tile answers only for its own footprint. A caller that read
|
||||
// `None` as "passable" would walk straight through every wall authored across a seam.
|
||||
assert_eq!(impassable_at(&here.chunks, east), None);
|
||||
assert_eq!(impassable_at(&next.chunks, pin), None);
|
||||
|
||||
// Not one stray chunk but an authored ribbon: Searing Gorge's rim (area 51) crosses this
|
||||
// tile, and the far side of the map's tiles carry none of it.
|
||||
assert_eq!(
|
||||
next.chunks.iter().filter(|c| c.impassable).count(),
|
||||
48,
|
||||
"Azeroth_33_44 authors 48 impassable chunks"
|
||||
);
|
||||
assert_eq!(here.chunks.iter().filter(|c| c.impassable).count(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! The **rigid bone spin** collector (`m2_bone_spins`) and its sampler — the mechanism that turns
|
||||
//! Caverns of Time's asteroid belts without a skinning palette (decision 1264's deferred half).
|
||||
//!
|
||||
//! Two halves, deliberately separate: the sampler is pure arithmetic and is pinned on synthetic
|
||||
//! keys, and the collector is pinned against the real shipped art, because what it has to get right
|
||||
//! is not arithmetic — it is *which* bones qualify. Both shipped skyboxes are asserted, including
|
||||
//! the one that must yield nothing: a predicate that accidentally admitted every bone would still
|
||||
//! look correct on the model that has motion.
|
||||
|
||||
use benilla_formats::{load_m2_bone_spins, m2_bone_spins, BoneSpin, Chain};
|
||||
|
||||
/// `[x, y, z, w]` for a rotation of `deg` about +Z — the axis-agnostic half of the sampler tests.
|
||||
fn about_z(deg: f32) -> [f32; 4] {
|
||||
let h = deg.to_radians() * 0.5;
|
||||
[0.0, 0.0, h.sin(), h.cos()]
|
||||
}
|
||||
|
||||
/// The angle (degrees) of a quaternion about its axis, sign-normalised — what a test can compare
|
||||
/// without caring which of `q`/`−q` the slerp produced.
|
||||
fn angle_deg(q: [f32; 4]) -> f32 {
|
||||
let w = q[3].abs().clamp(0.0, 1.0);
|
||||
2.0 * w.acos().to_degrees()
|
||||
}
|
||||
|
||||
fn spin(keys: Vec<(f32, [f32; 4])>, duration: f32, interp: bool) -> BoneSpin {
|
||||
BoneSpin {
|
||||
pivot: [0.0; 3],
|
||||
duration,
|
||||
interp,
|
||||
keys,
|
||||
}
|
||||
}
|
||||
|
||||
/// The sampler's four legs: hold before the first key, slerp between a bracket, clamp past the
|
||||
/// last, and wrap at the duration. The clamp is the one worth stating out loud — an M2 track's keys
|
||||
/// live inside the sequence band and the client interpolates within it, so a loop whose last key
|
||||
/// differs from its first *snaps* at the wrap. Inventing a wrap-around segment would be us
|
||||
/// animating rather than the file.
|
||||
#[test]
|
||||
fn the_sampler_holds_slerps_clamps_and_wraps() {
|
||||
let s = spin(
|
||||
vec![(0.0, about_z(0.0)), (2.0, about_z(90.0))],
|
||||
4.0,
|
||||
/*interp=*/ true,
|
||||
);
|
||||
assert!(angle_deg(s.sample(0.0)) < 1e-3, "at the first key");
|
||||
assert!(
|
||||
(angle_deg(s.sample(-1.0)) - 90.0).abs() < 1e-3,
|
||||
"a negative cursor lands 1 s before the loop start, i.e. 3 s into the previous cycle — \
|
||||
`rem_euclid`, not `%`, which would mirror it onto the loop's opening pose instead"
|
||||
);
|
||||
assert!(
|
||||
(angle_deg(s.sample(1.0)) - 45.0).abs() < 1e-2,
|
||||
"halfway through the bracket: {}",
|
||||
angle_deg(s.sample(1.0))
|
||||
);
|
||||
assert!(
|
||||
(angle_deg(s.sample(3.0)) - 90.0).abs() < 1e-3,
|
||||
"past the last key: clamped, NOT interpolated back toward key 0"
|
||||
);
|
||||
assert!(
|
||||
(angle_deg(s.sample(5.0)) - 45.0).abs() < 1e-2,
|
||||
"one full period on from t=1: the loop wrapped"
|
||||
);
|
||||
}
|
||||
|
||||
/// A step track (`interp_type == 0`) holds each key until the next. Our rig lane lerps these — a
|
||||
/// recorded divergence (`benilla-extract bonescan`) — and there is no reason to reproduce it in new
|
||||
/// code when the track's own header word is right there.
|
||||
#[test]
|
||||
fn a_step_track_holds_its_key() {
|
||||
let s = spin(
|
||||
vec![(0.0, about_z(0.0)), (2.0, about_z(90.0))],
|
||||
4.0,
|
||||
/*interp=*/ false,
|
||||
);
|
||||
assert!(angle_deg(s.sample(1.9)) < 1e-3, "still on key 0 at 1.9s");
|
||||
assert!((angle_deg(s.sample(2.0)) - 90.0).abs() < 1e-3);
|
||||
}
|
||||
|
||||
/// The shortest-arc negation in the slerp. Without it a bracket whose keys dot negative takes the
|
||||
/// LONG way round — which on a belt authored as a full turn reads as a section spinning backwards.
|
||||
#[test]
|
||||
fn the_slerp_takes_the_short_way_round() {
|
||||
// 350° and 0° are 10° apart, but their quaternions dot negative.
|
||||
let s = spin(
|
||||
vec![(0.0, about_z(350.0)), (1.0, about_z(360.0))],
|
||||
1.0,
|
||||
true,
|
||||
);
|
||||
let mid = angle_deg(s.sample(0.5));
|
||||
// 355° about +Z is 5° about −Z — either reading is ≤10° from both ends. The long way round
|
||||
// would put the midpoint ~175° away.
|
||||
assert!(
|
||||
!(15.0..=345.0).contains(&mid),
|
||||
"midpoint took the long arc: {mid}°"
|
||||
);
|
||||
}
|
||||
|
||||
/// The real asset. `CavernsOfTimeSky.m2` authors exactly three spinning bones — the asteroid belts,
|
||||
/// one 66.667 s loop, 25° / 90° / 360° of turn — and bone 0, which carries the other 17 batches,
|
||||
/// must NOT be among them (it has no track at all, and a collector that admitted it would rotate the
|
||||
/// entire painted sky).
|
||||
#[test]
|
||||
fn the_caverns_of_time_sky_spins_exactly_its_three_belt_bones() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = Chain::open(&data).expect("open vanilla patch chain");
|
||||
let spins = load_m2_bone_spins(&mut chain, "Environments\\Stars\\CavernsOfTimeSky.m2")
|
||||
.expect("read CavernsOfTimeSky.m2");
|
||||
|
||||
let mut bones: Vec<u16> = spins.keys().copied().collect();
|
||||
bones.sort_unstable();
|
||||
assert_eq!(
|
||||
bones,
|
||||
vec![1, 2, 3],
|
||||
"the three asteroid-belt bones, and only those"
|
||||
);
|
||||
|
||||
for (bone, turn) in [(1u16, 25.0f32), (2, 90.0), (3, 360.0)] {
|
||||
let s = &spins[&bone];
|
||||
assert!(
|
||||
(s.duration - 66.667).abs() < 0.01,
|
||||
"bone {bone}: one 66.667 s loop, got {}",
|
||||
s.duration
|
||||
);
|
||||
assert!(
|
||||
s.interp,
|
||||
"bone {bone}: the belts interpolate, they don't step"
|
||||
);
|
||||
assert!(
|
||||
angle_deg(s.keys[0].1) < 1e-2,
|
||||
"bone {bone}: the loop opens unrotated"
|
||||
);
|
||||
// The authored total turn, read at the last key. 360° comes back as 0° through `acos`
|
||||
// (the quaternion is back at identity), which is itself the thing to assert.
|
||||
let last = angle_deg(s.keys.last().expect("keyed").1);
|
||||
let expect = if turn >= 360.0 { 0.0 } else { turn };
|
||||
assert!(
|
||||
(last - expect).abs() < 0.5,
|
||||
"bone {bone}: expected {expect}° at the final key, got {last}°"
|
||||
);
|
||||
// The pivot is a few yards off the model origin — which is exactly why the placement has to
|
||||
// conjugate by it rather than rotate about the origin (the eye sits AT the origin).
|
||||
let d = (s.pivot[0].powi(2) + s.pivot[1].powi(2) + s.pivot[2].powi(2)).sqrt();
|
||||
assert!(
|
||||
(2.0..5.0).contains(&d),
|
||||
"bone {bone}: pivot {d:.2} yd from the origin — the conjugation matters"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The other shipped skybox has no animation at all, and the collector must say so. This is the
|
||||
/// half that catches a predicate gone loose: `StratholmeSkybox` is three static opaque batches, and
|
||||
/// anything it returns here is a bone the file never keys.
|
||||
#[test]
|
||||
fn the_stratholme_sky_spins_nothing() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = Chain::open(&data).expect("open vanilla patch chain");
|
||||
let bytes = chain
|
||||
.read_file("Environments\\Stars\\StratholmeSkybox.m2")
|
||||
.expect("read StratholmeSkybox.m2");
|
||||
assert!(
|
||||
m2_bone_spins(&bytes).is_empty(),
|
||||
"Stratholme's sky authors no bone animation"
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ use bevy::prelude::*;
|
||||
use bevy::render::render_resource::Face;
|
||||
|
||||
use crate::clutter::{scatter_tile_clutter, ClutterConfig, GroundClutter};
|
||||
use crate::collision::{GroundDecalSurface, PickOccluder};
|
||||
use crate::collision::{walk_layers, GroundDecalSurface, PickOccluder};
|
||||
use crate::interior::WmoResidency;
|
||||
use crate::lighting::SharedLightBuffer;
|
||||
use crate::liquid::{spawn_liquids, LiquidAssets};
|
||||
@@ -43,7 +43,7 @@ mod furnish;
|
||||
mod queries;
|
||||
mod spawn;
|
||||
|
||||
use collider::{finish_colliders, terrain_collider_data};
|
||||
use collider::{finish_colliders, impassable_wall_data, terrain_collider_data};
|
||||
use furnish::furnish_tile_cells;
|
||||
use spawn::prop_light::WmoDoodadInst;
|
||||
use spawn::spawn_loaded_placements;
|
||||
@@ -132,6 +132,10 @@ struct TileState {
|
||||
placements: Vec<u32>,
|
||||
/// The tile's water-surface entities (tile-exclusive, like the terrain mesh — despawned on unload).
|
||||
liquid: Vec<Entity>,
|
||||
/// The tile's impassable-chunk wall collider, if it authors any (decision 1266). Its own static
|
||||
/// body rather than a child of the root, matching the per-WMO walk/camera bakes: two colliders
|
||||
/// with different audiences are two bodies, not a nested one.
|
||||
wall: Option<Entity>,
|
||||
/// The tile's per-chunk `ClutterChunk` entities (tile-exclusive; their lazily-built clutter meshes
|
||||
/// are children, so despawning these cascades to them).
|
||||
clutter: Vec<Entity>,
|
||||
@@ -706,6 +710,7 @@ fn stream_terrain(
|
||||
furnished: false,
|
||||
placements: Vec::new(),
|
||||
liquid: Vec::new(),
|
||||
wall: None,
|
||||
clutter: Vec::new(),
|
||||
},
|
||||
);
|
||||
@@ -740,6 +745,12 @@ fn stream_terrain(
|
||||
// (attached by `finish_colliders`) so a tile streaming in never hitches the frame. It rides the
|
||||
// tile root's lifecycle — gone when the tile despawns, no separate bookkeeping.
|
||||
let collider_data = terrain_collider_data(&adt.chunks);
|
||||
// …and, separately, the tile's impassable-chunk fences (decision 1266, report B129). A
|
||||
// SECOND collider because the audience is the point: the reference's fence is emitted only
|
||||
// into the movement box gather, and its segment/ray path never reads the flag at all. So —
|
||||
// the walk layer, where the body sees it and the camera boom does not, and unmarked, so it
|
||||
// neither takes the selection ring nor clamps the mouse pick. An authored wall is not scenery.
|
||||
let wall_data = impassable_wall_data(&adt.chunks);
|
||||
// The tile ROOT draws nothing: it carries the collider and the surface roles, and its MCNK
|
||||
// cells hang off it as children — one drawn object per 33.333 yd chunk. That is the unit the
|
||||
// exterior-scene cull needs (decision 0780; a 533 yd slab the camera stands on intersects
|
||||
@@ -762,6 +773,15 @@ fn stream_terrain(
|
||||
));
|
||||
}
|
||||
tile.entity = Some(tile_ent.id());
|
||||
tile.wall = wall_data.map(|(verts, tris)| {
|
||||
commands
|
||||
.spawn(PendingCollider::new(
|
||||
build_collider_task(verts, tris),
|
||||
Some(walk_layers()),
|
||||
true,
|
||||
))
|
||||
.id()
|
||||
});
|
||||
tile.material = Some(material);
|
||||
activity.tiles_spawned += 1;
|
||||
|
||||
@@ -1023,7 +1043,7 @@ fn release_world(
|
||||
}
|
||||
|
||||
fn despawn_tile_owned(commands: &mut Commands, t: &TileState) {
|
||||
if let Some(e) = t.entity {
|
||||
for e in t.entity.into_iter().chain(t.wall) {
|
||||
commands.entity(e).try_despawn();
|
||||
}
|
||||
for &e in t.liquid.iter().chain(&t.clutter) {
|
||||
|
||||
@@ -202,6 +202,83 @@ pub(super) fn terrain_collider_data(
|
||||
(!tris.is_empty()).then_some((verts, tris))
|
||||
}
|
||||
|
||||
/// How far an impassable chunk's fence rises above the chunk's own floor — the reference's literal
|
||||
/// `u = (0, 0, 32000.0)` (immediate `0x46fa0000` at `0x6ab599`), VERIFIED by wow-re's §5 on
|
||||
/// `0x6ab530` (`system/terrain/scratch/impassable-chunk-walls.md`).
|
||||
///
|
||||
/// It is a *reach*, not a height: the quad is based at `chunk+0x4c`, the chunk AABB's **min z**, so
|
||||
/// the fence stands on the chunk's lowest ground and rises 32 km. Nothing extends below — a mover
|
||||
/// under the chunk floor (a mine, a tunnel, the sea bed) passes beneath the wall, and that is the
|
||||
/// law, not an omission.
|
||||
const FENCE_REACH: f32 = 32000.0;
|
||||
|
||||
/// The `(vertices, triangles)` for a tile's **impassable-chunk fences** — the ADT-level invisible
|
||||
/// wall of report B129, in the same world space as the terrain collider.
|
||||
///
|
||||
/// The mechanism is the reference's, VERIFIED (wow-re §5 on the emitter `0x6ab530`, reached from
|
||||
/// the movement box gather `0x6721b0 → 0x6aa8b0 → 0x6aadc0`; the flag is `CMapChunk+0xc & 0x40`,
|
||||
/// written word-wide at `0x6af5f0` from MCNK header bit 1, and `0x6aae2a` is its only reader in the
|
||||
/// image):
|
||||
///
|
||||
/// - **Additive, never subtractive.** The terrain under a flagged chunk stays exactly as walkable as
|
||||
/// any other; what the flag adds is a fence. Nothing is removed from the walkable set, which is
|
||||
/// why a flagged chunk holds you up rather than dropping you through.
|
||||
/// - **All four sides of every flagged chunk**, with no look at the neighbours. Two chunks side by
|
||||
/// side each keep their own shared-side fence, so a mover already inside the band cannot cross
|
||||
/// *within* it either — and the tile seam stops being a question, since nothing is ever asked
|
||||
/// about a chunk in another tile.
|
||||
/// - **Outward normals, so the fence is one-way.** Composed with the universal facing gate
|
||||
/// (`n·dir ≤ −1e-5`, decision 0970), entry is blocked and the same face is discarded on the way
|
||||
/// out: you cannot walk in, and if you start inside you can always walk out.
|
||||
/// - **Whole chunk.** The flag is tested once, before the reference's 8×8 cell loop opens — unlike
|
||||
/// holes and the no-doodad bits, which are per cell.
|
||||
/// - `n.z == 0` on all four, so a fence is never a floor and never something to step up onto.
|
||||
///
|
||||
/// The audience is the other half of the law and lives at the call site: the segment/ray path never
|
||||
/// reads the flag, so camera pull-in, mouse-pick and LOS are wall-blind, and terrain *height* is not
|
||||
/// gated at all.
|
||||
pub(super) fn impassable_wall_data(
|
||||
chunks: &[benilla_formats::ChunkMesh],
|
||||
) -> Option<(Vec<Vec3>, Vec<[u32; 3]>)> {
|
||||
let mut verts: Vec<Vec3> = Vec::new();
|
||||
let mut tris: Vec<[u32; 3]> = Vec::new();
|
||||
for chunk in chunks.iter().filter(|c| c.impassable) {
|
||||
// `chunk+0x4c`: the min corner of the AABB the reference folds over the chunk's 145 world
|
||||
// vertices (`0x6b0e50`) — the chunk's lowest ground, wherever in the chunk it happens to be.
|
||||
let Some(floor) = chunk.positions.iter().map(|p| p[2]).reduce(f32::min) else {
|
||||
continue; // a chunk with no vertices has no AABB to stand a fence on
|
||||
};
|
||||
// The four footprint sides, from the corners of the 9×9 outer grid (stride 17: row `r`
|
||||
// starts at `r·17`), each walked so that `(b−a)×(c−a)` points AWAY from the chunk. With +X
|
||||
// north, +Y west and +Z up, a side whose outward normal is `n` must be walked along
|
||||
// `t = ẑ × n`: south down the west side, north up the east side, west along the north side,
|
||||
// east along the south side.
|
||||
const SIDES: [[usize; 2]; 4] = [
|
||||
[8, 0], // north (row 0), walked east → west
|
||||
[136, 144], // south (row 8), walked west → east
|
||||
[0, 136], // west (col 0), walked north → south
|
||||
[144, 8], // east (col 8), walked south → north
|
||||
];
|
||||
for [from, to] in SIDES {
|
||||
let (Some(a), Some(b)) = (chunk.positions.get(from), chunk.positions.get(to)) else {
|
||||
continue; // a chunk without its outer grid has no side to stand a fence on
|
||||
};
|
||||
let base = verts.len() as u32;
|
||||
for (p, z) in [
|
||||
(a, floor),
|
||||
(b, floor),
|
||||
(b, floor + FENCE_REACH),
|
||||
(a, floor + FENCE_REACH),
|
||||
] {
|
||||
verts.push(wow_to_bevy([p[0], p[1], z]));
|
||||
}
|
||||
tris.push([base, base + 1, base + 2]);
|
||||
tris.push([base, base + 2, base + 3]);
|
||||
}
|
||||
}
|
||||
(!tris.is_empty()).then_some((verts, tris))
|
||||
}
|
||||
|
||||
/// Build a static-trimesh [`Collider`] on the **async compute pool** (parry's QBVH construction is the
|
||||
/// hundreds-of-ms cost that hitched the frame on big-WMO / terrain stream-in). [`finish_colliders`]
|
||||
/// attaches the result. A few-frame delay in collision for just-streamed *static* geometry is
|
||||
@@ -217,6 +294,255 @@ mod tests {
|
||||
use bevy::app::TaskPoolPlugin;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
use benilla_formats::{ChunkMesh, CHUNK_SIZE};
|
||||
|
||||
/// One MCNK at tile-grid `(index_x, index_y)`, flat at z = 0, carrying only what the wall
|
||||
/// builder reads: the 9×9 outer grid in its stride-17 slots, the indices, and the flag. The
|
||||
/// tile's NW corner sits at the WoW origin, so chunk `(ix, iy)` runs from `(−iy·C, −ix·C)`
|
||||
/// south and east — rows step −x, columns step −y, exactly as `adt_to_tile_mesh` lays them out.
|
||||
fn chunk(ix: u32, iy: u32, impassable: bool) -> ChunkMesh {
|
||||
let (nw_x, nw_y) = (-(iy as f32) * CHUNK_SIZE, -(ix as f32) * CHUNK_SIZE);
|
||||
let mut positions = vec![[0.0_f32; 3]; 145];
|
||||
for r in 0..9usize {
|
||||
for c in 0..9usize {
|
||||
positions[r * 17 + c] = [
|
||||
nw_x - r as f32 * CHUNK_SIZE / 8.0,
|
||||
nw_y - c as f32 * CHUNK_SIZE / 8.0,
|
||||
0.0,
|
||||
];
|
||||
}
|
||||
}
|
||||
ChunkMesh {
|
||||
positions,
|
||||
normals: Vec::new(),
|
||||
uvs: Vec::new(),
|
||||
indices: Vec::new(),
|
||||
holes: 0,
|
||||
base_texture: None,
|
||||
layer_textures: Vec::new(),
|
||||
layer_effect_ids: Vec::new(),
|
||||
alpha_map: None,
|
||||
shadow: None,
|
||||
pred_tex: [0; 64],
|
||||
no_effect_doodad: [false; 64],
|
||||
index_x: ix,
|
||||
index_y: iy,
|
||||
area_id: 0,
|
||||
impassable,
|
||||
liquids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The centre of chunk `(ix, iy)`'s footprint in Bevy space, at z = 0.
|
||||
fn chunk_centre(ix: u32, iy: u32) -> Vec3 {
|
||||
wow_to_bevy([
|
||||
-(iy as f32 + 0.5) * CHUNK_SIZE,
|
||||
-(ix as f32 + 0.5) * CHUNK_SIZE,
|
||||
0.0,
|
||||
])
|
||||
}
|
||||
|
||||
/// A flagged chunk is boxed in, and every face is wound OUTWARD — which is the whole mechanism:
|
||||
/// composed with the one-sided movement law (0970), the fence blocks a mover walking in and is
|
||||
/// discarded from behind, so the band cannot be entered and never traps what starts inside it.
|
||||
#[test]
|
||||
fn a_flagged_chunk_is_boxed_in_by_outward_facing_walls() {
|
||||
let chunks: Vec<ChunkMesh> = [(1, 1, true), (0, 1, false), (1, 0, false)]
|
||||
.into_iter()
|
||||
.map(|(ix, iy, f)| chunk(ix, iy, f))
|
||||
.collect();
|
||||
let (verts, tris) = impassable_wall_data(&chunks).expect("one flagged chunk, four walls");
|
||||
assert_eq!(tris.len(), 8, "four sides, two triangles each");
|
||||
|
||||
let centre = chunk_centre(1, 1);
|
||||
for t in &tris {
|
||||
let (a, b, c) = (
|
||||
verts[t[0] as usize],
|
||||
verts[t[1] as usize],
|
||||
verts[t[2] as usize],
|
||||
);
|
||||
let n = (b - a).cross(c - a).normalize();
|
||||
assert!(n.y.abs() < 1e-5, "a wall face is vertical: {n:?}");
|
||||
let face = (a + b + c) / 3.0;
|
||||
assert!(
|
||||
n.dot(face - centre) > 0.0,
|
||||
"face at {face:?} is wound back INTO the chunk (normal {n:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every flagged chunk keeps its **own** four sides, neighbours unread — so two side by side
|
||||
/// carry a doubled fence on the boundary they share, wound apart. That is not redundancy: it is
|
||||
/// what stops a mover already inside the band from crossing *within* it, since each chunk's
|
||||
/// fence blocks entry to that chunk from any direction. It also makes the tile seam a non-issue,
|
||||
/// because nothing is ever asked about a chunk in another tile — the case B129 is (the flagged
|
||||
/// chunk is column 0 of `Azeroth_33_44`, the pin is column 15 of `Azeroth_32_44`).
|
||||
#[test]
|
||||
fn every_flagged_chunk_fences_all_four_of_its_own_sides() {
|
||||
let chunks: Vec<ChunkMesh> = [(1, 1, true), (2, 1, true), (0, 1, false), (3, 1, false)]
|
||||
.into_iter()
|
||||
.map(|(ix, iy, f)| chunk(ix, iy, f))
|
||||
.collect();
|
||||
let (verts, tris) = impassable_wall_data(&chunks).expect("two flagged chunks");
|
||||
assert_eq!(tris.len(), 16, "four sides each, two triangles a side");
|
||||
|
||||
// The shared plane is WoW y = −2·CHUNK_SIZE ⇒ Bevy x = +2·CHUNK_SIZE. Both chunks put a
|
||||
// face IN it, and the two must face opposite ways — each blocking entry to its own chunk.
|
||||
let shared = 2.0 * CHUNK_SIZE;
|
||||
let normals: Vec<Vec3> = tris
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t.iter()
|
||||
.all(|&i| (verts[i as usize].x - shared).abs() < 1e-3)
|
||||
})
|
||||
.map(|t| {
|
||||
let (a, b, c) = (
|
||||
verts[t[0] as usize],
|
||||
verts[t[1] as usize],
|
||||
verts[t[2] as usize],
|
||||
);
|
||||
(b - a).cross(c - a).normalize()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(normals.len(), 4, "two coplanar quads on the shared side");
|
||||
assert!(
|
||||
normals.iter().any(|n| n.x > 0.9) && normals.iter().any(|n| n.x < -0.9),
|
||||
"the shared side is fenced from both directions: {normals:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A fence stands on the chunk's own floor and only ever rises — the reference bases the quad
|
||||
/// at the chunk AABB's min z and adds `(0, 0, 32000)`. Nothing below: a mover under the chunk
|
||||
/// floor (a mine, a tunnel, the sea bed) passes beneath the wall, and that is the law.
|
||||
#[test]
|
||||
fn a_fence_stands_on_the_chunk_floor_and_only_rises() {
|
||||
// A chunk dished 12 yd below its corners, so "the floor" is a real minimum and not just the
|
||||
// corner height the sides are built from.
|
||||
let mut c = chunk(1, 1, true);
|
||||
c.positions[4 * 17 + 4][2] = -12.0;
|
||||
let (verts, _) = impassable_wall_data(&[c]).expect("walls");
|
||||
let (lo, hi) = verts.iter().fold((f32::MAX, f32::MIN), |(lo, hi), v| {
|
||||
(lo.min(v.y), hi.max(v.y))
|
||||
});
|
||||
assert!(
|
||||
(lo - -12.0).abs() < 1e-3,
|
||||
"based at the chunk floor, got {lo}"
|
||||
);
|
||||
assert!(
|
||||
(hi - (-12.0 + FENCE_REACH)).abs() < 1e-3,
|
||||
"rises {FENCE_REACH} from the floor, got {hi}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The overwhelmingly common tile: no flag, no collider, nothing attached.
|
||||
#[test]
|
||||
fn a_tile_with_no_flagged_chunk_builds_no_wall() {
|
||||
let chunks = vec![chunk(0, 0, false), chunk(1, 0, false)];
|
||||
assert!(impassable_wall_data(&chunks).is_none());
|
||||
}
|
||||
|
||||
/// B129 end to end, on the shipped bytes and through the real cast: a body walking east from
|
||||
/// Goudy's pin (`.go xyz -6601.98 -531.87 335.60 0`) is stopped where 1.12.1 stops it, at the
|
||||
/// chunk boundary 1.46 yd away — and the SAME world built without the walls carries it straight
|
||||
/// through, which is the report. Both halves matter: the second is the symptom reproduced, the
|
||||
/// first is it gone, and a wall built at the wrong offset would satisfy neither. Skips without
|
||||
/// client data.
|
||||
#[test]
|
||||
fn a_body_walking_east_from_the_b129_pin_is_stopped_at_the_wall() {
|
||||
/// Goudy's pin, and the MCNK boundary the flagged chunk starts at (WoW y; east is −y).
|
||||
const PIN: [f32; 3] = [-6601.98, -531.87, 335.60];
|
||||
const WALL_Y: f32 = 32.0 * benilla_formats::TILE_SIZE - 528.0 * CHUNK_SIZE;
|
||||
const R: f32 = 0.5;
|
||||
const SKIN: f32 = 0.01;
|
||||
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||||
// Both sides of the seam: the pin's tile and the one that authors the wall.
|
||||
let tiles: Vec<benilla_formats::TileMesh> = [(32, 44), (33, 44)]
|
||||
.into_iter()
|
||||
.map(|(tx, ty)| {
|
||||
benilla_formats::load_tile_mesh(&mut chain, "Azeroth", tx, ty)
|
||||
.expect("Azeroth tile")
|
||||
})
|
||||
.collect();
|
||||
let ground = tiles
|
||||
.iter()
|
||||
.find_map(|t| benilla_formats::terrain_height_at(&t.chunks, PIN))
|
||||
.expect("terrain under the pin");
|
||||
|
||||
// How far the body gets walking east from a given height, in a world with the walls and in
|
||||
// one without.
|
||||
let run_east = |walls: bool, z: f32| -> Option<f32> {
|
||||
let mut app = App::new();
|
||||
app.add_plugins((
|
||||
MinimalPlugins,
|
||||
bevy::transform::TransformPlugin,
|
||||
bevy::asset::AssetPlugin::default(),
|
||||
bevy::scene::ScenePlugin,
|
||||
avian3d::prelude::PhysicsPlugins::new(bevy::app::PostUpdate),
|
||||
));
|
||||
app.init_asset::<Mesh>();
|
||||
for t in &tiles {
|
||||
if let Some((v, i)) = terrain_collider_data(&t.chunks) {
|
||||
app.world_mut().spawn((
|
||||
RigidBody::Static,
|
||||
Collider::trimesh(v, i),
|
||||
Transform::default(),
|
||||
));
|
||||
}
|
||||
if let Some((v, i)) = walls.then(|| impassable_wall_data(&t.chunks)).flatten() {
|
||||
app.world_mut().spawn((
|
||||
RigidBody::Static,
|
||||
Collider::trimesh(v, i),
|
||||
crate::collision::walk_layers(),
|
||||
Transform::default(),
|
||||
));
|
||||
}
|
||||
}
|
||||
app.update(); // builds Position/Rotation and the spatial-query trees
|
||||
app.world_mut()
|
||||
.run_system_once(move |world: crate::collision::WorldCollision| {
|
||||
let capsule = Collider::capsule(R, 1.0);
|
||||
// East is −y in WoW, +x in Bevy.
|
||||
let from = wow_to_bevy([PIN[0], PIN[1], z]);
|
||||
world
|
||||
.cast_body(&capsule, from, Vec3::X * 5.0, SKIN)
|
||||
.map(|h| h.distance)
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Feet on the ground at the pin.
|
||||
let walking = ground + 1.0 + R;
|
||||
// The report: nothing at all stops the body inside the 5 yd it walks.
|
||||
assert!(
|
||||
run_east(false, walking).is_none(),
|
||||
"B129 itself — the terrain here does not stop a body, which is why the flag has to"
|
||||
);
|
||||
// The fix: stopped, with the capsule's leading surface against the chunk boundary.
|
||||
let d = run_east(true, walking).expect("the impassable chunk's fence stops the body");
|
||||
let leading_edge = -(wow_to_bevy([PIN[0], PIN[1], 0.0]).x + d) - R;
|
||||
assert!(
|
||||
(leading_edge - (WALL_Y + SKIN)).abs() < 0.05,
|
||||
"stopped at y={leading_edge}, wall is at y={WALL_Y} (travelled {d} yd)"
|
||||
);
|
||||
|
||||
// …and the other half of the same law: the fence is based at the flagged chunk's own floor
|
||||
// and only rises, so a body BELOW that floor — a tunnel, a mine, the sea bed — crosses
|
||||
// freely. Under the ground and out of the terrain's way, the only thing that could stop
|
||||
// this cast is a fence reaching down, and the reference's does not.
|
||||
let floor = tiles
|
||||
.iter()
|
||||
.flat_map(|t| t.chunks.iter())
|
||||
.filter(|c| c.impassable)
|
||||
.filter_map(|c| c.positions.iter().map(|p| p[2]).reduce(f32::min))
|
||||
.fold(f32::MAX, f32::min);
|
||||
assert!(
|
||||
run_east(true, floor - 20.0).is_none(),
|
||||
"the fence reached below the chunk floor (floor {floor})"
|
||||
);
|
||||
}
|
||||
|
||||
/// A tile-sized grid: 256 quads -> 512 triangles per chunk, `chunks` chunks.
|
||||
fn grid(chunks: usize) -> (Vec<Vec3>, Vec<[u32; 3]>) {
|
||||
let (mut verts, mut tris) = (Vec::new(), Vec::new());
|
||||
|
||||
@@ -95,6 +95,29 @@ pub struct CameraWmoSkybox(pub Option<String>);
|
||||
#[derive(Component)]
|
||||
struct WmoSkyboxPart(String);
|
||||
|
||||
/// This batch **spins**: every one of its vertices is wholly weighted to one parentless
|
||||
/// rotation-only bone, so the authored motion is a rigid transform about that bone's pivot and needs
|
||||
/// no skinning palette at all ([`benilla_formats::BoneSpin`]).
|
||||
///
|
||||
/// That is the whole reason the belts can turn here rather than on the `M2Model` asset lane, which
|
||||
/// is what decision 1264 assumed this would cost. A rig would want joint entities under the anchor,
|
||||
/// and the anchor is written *post-propagation* ([`follow_camera`], decision 0504's same-frame
|
||||
/// camera pose) — so its children's globals would be a frame stale, and fixing that means either
|
||||
/// giving up the same-frame pose or hand-writing joint globals. A rigid batch sidesteps the whole
|
||||
/// question: there are no children.
|
||||
///
|
||||
/// **Population, measured** (`benilla-extract m2batch`/`m2bones`): `CavernsOfTimeSky.m2`'s four
|
||||
/// asteroid-belt batches, `[1@1.00]×38`, `[2@1.00]×38`, `[3@1.00]×38` and `[3@1.00]×28`, on three
|
||||
/// parentless bones keying rotation alone over one 66.667 s loop. The other 17 batches ride bone 0,
|
||||
/// which has no track, and `StratholmeSkybox.m2` has no animation at all — so this is the entire
|
||||
/// animated content of every skybox the chain ships.
|
||||
#[derive(Component)]
|
||||
struct SkyboxSpin {
|
||||
/// The bone's pivot, in the same (Bevy) space the batch's vertices were baked into.
|
||||
pivot: Vec3,
|
||||
spin: benilla_formats::BoneSpin,
|
||||
}
|
||||
|
||||
/// Which skybox paths have been built already — a build is a chain read + BLP decodes, so it happens
|
||||
/// once per path per session and the entities are then just shown/hidden. A path that FAILED to load
|
||||
/// is recorded here too: the retry would fail identically every frame, and the gradient dome is the
|
||||
@@ -156,8 +179,11 @@ fn resolve_camera_skybox(
|
||||
mut want: ResMut<CameraWmoSkybox>,
|
||||
) {
|
||||
// `min()` rather than "first match": `Query` iteration order is not stable across frames, and a
|
||||
// tie would otherwise alternate two backdrops frame to frame. Only one 1.12 root can qualify
|
||||
// (Stratholme_B — the four Caverns of Time shells are unreleased), so this never actually picks.
|
||||
// tie would otherwise alternate two backdrops frame to frame. Five roots qualify — Stratholme_B
|
||||
// and the four Caverns of Time shells — and the tie-break is live code, not a formality: the
|
||||
// note that used to sit here read "unreleased" as "unreachable" and concluded this can never
|
||||
// pick twice. `CavernsofTime.wmo` is placed in the live world (decision 1264, and the module
|
||||
// header above); two of its shells overlapping the camera is exactly the case `min()` settles.
|
||||
let resolved = instances
|
||||
.iter()
|
||||
.filter_map(|inst| {
|
||||
@@ -211,6 +237,15 @@ fn build_skybox(
|
||||
let Some(mut world_assets) = world_assets else {
|
||||
return; // assetless dev run — the gradient dome stays the backdrop
|
||||
};
|
||||
// The model's rigid spins, keyed by bone — empty for `StratholmeSkybox` and for a capture
|
||||
// (`deterministic_run`), which keeps every animated lane on its bind pose so world baselines stay
|
||||
// comparable across runs and branches, exactly as the doodad host's own arm does.
|
||||
let spins = if crate::dev_state::deterministic_run() {
|
||||
Default::default()
|
||||
} else {
|
||||
benilla_formats::load_m2_bone_spins(&mut world_assets.chain.lock_recover(), path)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let subs = benilla_formats::load_m2_mesh(&mut world_assets.chain.lock_recover(), path);
|
||||
let subs = match subs {
|
||||
Ok(subs) if !subs.is_empty() => subs,
|
||||
@@ -260,17 +295,46 @@ fn build_skybox(
|
||||
let Some(material) = mats.skybox(sub, texture, u16::try_from(i + 1).unwrap_or(0)) else {
|
||||
return; // light buffer vanished mid-build; `built` is unlatched, so we retry
|
||||
};
|
||||
commands.spawn((
|
||||
Mesh3d(meshes.add(mesh)),
|
||||
MeshMaterial3d(material),
|
||||
Transform::default(),
|
||||
Visibility::Hidden, // `apply_skybox_visibility` turns on exactly the wanted one
|
||||
WmoSkyboxPart(path.to_string()),
|
||||
));
|
||||
let part = commands
|
||||
.spawn((
|
||||
Mesh3d(meshes.add(mesh)),
|
||||
MeshMaterial3d(material),
|
||||
Transform::default(),
|
||||
Visibility::Hidden, // `apply_skybox_visibility` turns on exactly the wanted one
|
||||
WmoSkyboxPart(path.to_string()),
|
||||
))
|
||||
.id();
|
||||
if let Some(spin) = sole_bone(sub).and_then(|b| spins.get(&b)) {
|
||||
commands.entity(part).insert(SkyboxSpin {
|
||||
pivot: wow_to_bevy(spin.pivot),
|
||||
spin: spin.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
built.0.insert(path.to_string());
|
||||
}
|
||||
|
||||
/// The bone this batch is **wholly** weighted to, if any — the caller's half of
|
||||
/// [`benilla_formats::BoneSpin`]'s rigid-body condition, and the one half the bone table cannot
|
||||
/// answer: a vertex split between this bone and another is half a rigid body's worth of motion,
|
||||
/// which no single transform can produce.
|
||||
///
|
||||
/// `None` for a batch with no skin binding at all (every WMO batch, and any loader that didn't fill
|
||||
/// it) — the conservative answer, since an unweighted batch has nothing to spin about.
|
||||
fn sole_bone(sub: &benilla_formats::RenderSubmesh) -> Option<u16> {
|
||||
let bone = sub.joints.first()?[0];
|
||||
(sub.weights.len() == sub.joints.len()
|
||||
&& sub
|
||||
.joints
|
||||
.iter()
|
||||
.zip(&sub.weights)
|
||||
// `> 0.999` rather than `== 1.0`: the weights are the file's 0..255 bytes normalised by
|
||||
// their sum, so a wholly-bound vertex is exactly 1.0 today — but an equality test on a
|
||||
// divided float is the kind of thing that silently stops matching.
|
||||
.all(|(j, w)| j[0] == bone && w[0] > 0.999))
|
||||
.then_some(bone)
|
||||
}
|
||||
|
||||
/// Show the wanted skybox's batches and hide every other built one. This is the sole `Visibility`
|
||||
/// writer for these entities (the gradient dome's own gate lives in [`crate::sky`], which reads the
|
||||
/// same resource — one authority per entity class, decision 0025).
|
||||
@@ -312,22 +376,88 @@ fn apply_skybox_visibility(
|
||||
/// not incidental — the near-black ±Z pair covers only a **34.7°** cone about the zenith rather than
|
||||
/// a symmetric 45°, and the side pairs' painted horizon lands three-quarters down their gradient
|
||||
/// (v ≈ 0.72). Re-centring the box on the eye would be a visible change and a wrong one.
|
||||
/// A spinning batch ([`SkyboxSpin`]) composes its bone's rotation about the bone's own pivot INSIDE
|
||||
/// the anchor: `T(eye) · T(pivot) · R(t) · T(−pivot)`, which closes to the rotation itself plus the
|
||||
/// translation below. The pivot conjugation is not decoration — Caverns of Time's belt bones pivot
|
||||
/// ~3 yd off the model origin, and the eye sits AT that origin ~20 yd from the belt, so dropping it
|
||||
/// swings the whole ring through several degrees instead of turning it in place.
|
||||
///
|
||||
/// The clock is the scene's own elapsed time, wrapped by the sequence. **The phase origin is not
|
||||
/// byte-pinned** — the reference arms the model at load and samples a shared clock, so its phase is
|
||||
/// whatever the room's load moment was — and for a 66.7 s loop of an asteroid ring with no start
|
||||
/// event, phase is unobservable. Captures never reach here at all (the spin component is not
|
||||
/// attached under a deterministic run), so no golden frame depends on it.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn follow_camera(
|
||||
time: Res<Time>,
|
||||
cam: Query<&GlobalTransform, With<WorldCamera>>,
|
||||
mut parts: Query<
|
||||
(&mut Transform, &mut GlobalTransform),
|
||||
(&mut Transform, &mut GlobalTransform, Option<&SkyboxSpin>),
|
||||
(With<WmoSkyboxPart>, Without<WorldCamera>),
|
||||
>,
|
||||
) {
|
||||
let Some(cam_gt) = cam.iter().next() else {
|
||||
return;
|
||||
};
|
||||
for (mut tf, mut gt) in &mut parts {
|
||||
tf.translation = cam_gt.translation();
|
||||
tf.rotation = Quat::IDENTITY;
|
||||
let now = time.elapsed_secs();
|
||||
for (mut tf, mut gt, spin) in &mut parts {
|
||||
let (rot, pivot) = match spin {
|
||||
Some(s) => (
|
||||
benilla_assets::coords::wow_rotation_to_bevy(s.spin.sample(now)),
|
||||
s.pivot,
|
||||
),
|
||||
None => (Quat::IDENTITY, Vec3::ZERO),
|
||||
};
|
||||
tf.translation = cam_gt.translation() + pivot - rot * pivot;
|
||||
tf.rotation = rot;
|
||||
tf.scale = Vec3::ONE;
|
||||
// Propagation already ran this frame — the direct global write is what renders.
|
||||
*gt = GlobalTransform::from(*tf);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The renderer's half of the rigid-spin predicate, on the real art: exactly the four
|
||||
/// asteroid-belt batches resolve to a bone the collector spins, and the other seventeen resolve
|
||||
/// to bone 0 — which is *weighted* wholly, and simply has no track.
|
||||
///
|
||||
/// The distinction matters and is why this asserts both columns. `sole_bone` returning `Some(0)`
|
||||
/// for the painted cube is correct and harmless; it is the SPIN lookup that must come back empty
|
||||
/// for it. A test that only checked "four parts spin" would pass just as well if the predicate
|
||||
/// were selecting the wrong four.
|
||||
#[test]
|
||||
fn only_the_belt_batches_of_the_caverns_sky_resolve_to_a_spinning_bone() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
let mut chain = benilla_formats::Chain::open(&data).expect("open vanilla patch chain");
|
||||
const SKY: &str = "Environments\\Stars\\CavernsOfTimeSky.m2";
|
||||
let subs = benilla_formats::load_m2_mesh(&mut chain, SKY).expect("load the sky");
|
||||
let spins = benilla_formats::load_m2_bone_spins(&mut chain, SKY).expect("its spins");
|
||||
|
||||
let bones: Vec<Option<u16>> = subs.iter().map(sole_bone).collect();
|
||||
assert_eq!(bones.len(), 21, "21 authored batches");
|
||||
// Batches 5..=8 are the belts (`m2batch`: `[1@1.00]×38`, `[2@1.00]×38`, `[3@1.00]×38`,
|
||||
// `[3@1.00]×28`); every other batch rides bone 0.
|
||||
assert_eq!(
|
||||
&bones[5..=8],
|
||||
&[Some(1), Some(2), Some(3), Some(3)],
|
||||
"the belt batches and the bones they ride"
|
||||
);
|
||||
assert!(
|
||||
bones
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| !(5..=8).contains(i))
|
||||
.all(|(_, b)| *b == Some(0)),
|
||||
"every non-belt batch is wholly on bone 0: {bones:?}"
|
||||
);
|
||||
|
||||
let spinning = bones
|
||||
.iter()
|
||||
.filter(|b| b.and_then(|b| spins.get(&b)).is_some())
|
||||
.count();
|
||||
assert_eq!(spinning, 4, "exactly the four belt batches turn");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user