diff --git a/crates/benilla-adt/src/lib.rs b/crates/benilla-adt/src/lib.rs index 854f507e..4d0ce026 100644 --- a/crates/benilla-adt/src/lib.rs +++ b/crates/benilla-adt/src/lib.rs @@ -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 { diff --git a/crates/benilla-assets/src/coords.rs b/crates/benilla-assets/src/coords.rs index 3cc44cb7..92c9865d 100644 --- a/crates/benilla-assets/src/coords.rs +++ b/crates/benilla-assets/src/coords.rs @@ -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::*; diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index acf965a9..3f0fadc5 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -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) -> vec3 { + let l2 = dot(v, v); + return select(vec3(0.0), normalize(v), l2 > 1e-12); +} + fn point_light_sum(P: vec3, N: vec3, anchor: vec3) -> vec3 { let count = u32(wow_light.point_count.x); var sel = array(0u, 0u, 0u); @@ -363,7 +386,7 @@ fn inverse_transpose_3x3m(in: mat3x3) -> mat3x3 { // (Translation-free by construction — so the rig-relative frame of decision 0974 feeds it // unchanged: a normal never cared where the rig stands.) fn wow_skin_normals(frame_from_local: mat4x4, normal: vec3) -> vec3 { - return normalize( + return wow_normalize( inverse_transpose_3x3m(mat3x3( 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 diff --git a/crates/benilla-formats/examples/impass_here.rs b/crates/benilla-formats/examples/impass_here.rs new file mode 100644 index 00000000..b9809b1f --- /dev/null +++ b/crates/benilla-formats/examples/impass_here.rs @@ -0,0 +1,136 @@ +//! Where the ADT's impassable chunks are around a pin: +//! `cargo run -p benilla-formats --example impass_here -- [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 [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> = HashMap::new(); + let mut flag_at = |chain: &mut _, sx: f32, sy: f32| -> Option { + 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(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index f2a55d8a..470c1948 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -212,6 +212,18 @@ enum Command { /// Internal-path prefix filter (e.g. `spells`), case-insensitive; all models if omitted. prefix: Option, }, + /// 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, + }, /// 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())?, diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/geometry.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/geometry.rs index 100a19ab..41e33d2f 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/geometry.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/geometry.rs @@ -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(()) +} diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs index a95a0350..3d200280 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs @@ -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::{ diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index e5616a61..7bc006b1 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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 diff --git a/crates/benilla-formats/src/models/anim.rs b/crates/benilla-formats/src/models/anim.rs index 291fa5bb..cc867c0f 100644 --- a/crates/benilla-formats/src/models/anim.rs +++ b/crates/benilla-formats/src/models/anim.rs @@ -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> { 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 { + 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 diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs index f0eb2e46..f58c71eb 100644 --- a/crates/benilla-formats/src/models/m2_batches.rs +++ b/crates/benilla-formats/src/models/m2_batches.rs @@ -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> { + 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 diff --git a/crates/benilla-formats/src/models/types.rs b/crates/benilla-formats/src/models/types.rs index 4294a2c1..0bdf7b0f 100644 --- a/crates/benilla-formats/src/models/types.rs +++ b/crates/benilla-formats/src/models/types.rs @@ -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::(); + 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::().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. diff --git a/crates/benilla-formats/src/terrain.rs b/crates/benilla-formats/src/terrain.rs index bd605aa3..7ba470ba 100644 --- a/crates/benilla-formats/src/terrain.rs +++ b/crates/benilla-formats/src/terrain.rs @@ -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 { + 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 { 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 { + 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 { 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); + } } diff --git a/crates/benilla-formats/tests/m2_bone_spin.rs b/crates/benilla-formats/tests/m2_bone_spin.rs new file mode 100644 index 00000000..48e9112c --- /dev/null +++ b/crates/benilla-formats/tests/m2_bone_spin.rs @@ -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 = 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" + ); +} diff --git a/crates/benilla-world/src/terrain_stream.rs b/crates/benilla-world/src/terrain_stream.rs index b9222d82..b6602483 100644 --- a/crates/benilla-world/src/terrain_stream.rs +++ b/crates/benilla-world/src/terrain_stream.rs @@ -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, /// The tile's water-surface entities (tile-exclusive, like the terrain mesh — despawned on unload). liquid: Vec, + /// 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, /// The tile's per-chunk `ClutterChunk` entities (tile-exclusive; their lazily-built clutter meshes /// are children, so despawning these cascades to them). clutter: Vec, @@ -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) { diff --git a/crates/benilla-world/src/terrain_stream/collider.rs b/crates/benilla-world/src/terrain_stream/collider.rs index e67ca974..ebea424b 100644 --- a/crates/benilla-world/src/terrain_stream/collider.rs +++ b/crates/benilla-world/src/terrain_stream/collider.rs @@ -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, Vec<[u32; 3]>)> { + let mut verts: Vec = 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 = [(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 = [(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 = 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 = [(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 { + 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::(); + 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, Vec<[u32; 3]>) { let (mut verts, mut tris) = (Vec::new(), Vec::new()); diff --git a/crates/benilla-world/src/wmo_sky.rs b/crates/benilla-world/src/wmo_sky.rs index 52cc20cd..1b4fdd6f 100644 --- a/crates/benilla-world/src/wmo_sky.rs +++ b/crates/benilla-world/src/wmo_sky.rs @@ -95,6 +95,29 @@ pub struct CameraWmoSkybox(pub Option); #[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, ) { // `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 { + 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