From b932302d7932a63bbf2adc0157a45e3d59f298f8 Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 17 Aug 2026 17:27:28 +0700 Subject: [PATCH] the lava bubbles move again, and the bonfire keeps its wood MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Blackrock Mountain's lava bubbles animate at last: their sprite's flip-book channel was baked from a sequence they never play — UV and tint loops now bake per sequence, and a placement whose sequences disagree gets its own material - a bonfire's logs were vanishing while its flame burned on: a body planted just under its own floor now claims the room above it, and the flame card and fire sound follow the body's cull instead of outliving it - a WMO prop's animation host stops being cull-tested as an object in its own right - WOW_TILE_RADIUS's doc now states its real default, 2, and why Co-Authored-By: Claude Fable 5 --- crates/benilla-app/src/pipe_warm/menagerie.rs | 13 + crates/benilla-assets/src/m2.rs | 2 + crates/benilla-assets/src/model.rs | 7 + .../benilla-assets/src/shaders/wow_model.wgsl | 2 +- crates/benilla-assets/src/wmo.rs | 4 +- crates/benilla-assets/src/world_assets/mod.rs | 8 +- .../src/bin/benilla-extract/main.rs | 36 ++ .../src/bin/benilla-extract/scan/lighting.rs | 46 +- .../src/bin/benilla-extract/scan/material.rs | 493 +++++++++++++++++- .../src/bin/benilla-extract/scan/mod.rs | 50 +- crates/benilla-formats/src/lib.rs | 6 +- crates/benilla-formats/src/models/key_anim.rs | 129 +++++ .../benilla-formats/src/models/m2_batches.rs | 19 +- crates/benilla-formats/src/models/mat_anim.rs | 20 +- crates/benilla-formats/src/models/mod.rs | 3 + crates/benilla-formats/src/models/tex_anim.rs | 35 +- crates/benilla-formats/src/models/types.rs | 12 + .../tests/m2_billboard_card_face.rs | 2 + .../tests/m2_uv_anim_variation.rs | 98 ++++ crates/benilla-world/src/billboard.rs | 76 +-- .../benilla-world/src/doodad_anim/mat_anim.rs | 206 ++++++-- crates/benilla-world/src/doodad_anim/mod.rs | 2 +- crates/benilla-world/src/mat_anim_table.rs | 16 +- crates/benilla-world/src/model_render.rs | 22 + .../benilla-world/src/model_render/batch.rs | 7 + .../src/model_render/visibility.rs | 132 ++++- crates/benilla-world/src/particles/model.rs | 1 + crates/benilla-world/src/particles/sim.rs | 60 ++- .../src/terrain_stream/spawn/assemble.rs | 70 ++- .../src/terrain_stream/spawn/mod.rs | 9 +- .../benilla-world/src/wmo_portal/interior.rs | 63 ++- crates/benilla-world/src/wmo_portal/seed.rs | 37 ++ 32 files changed, 1509 insertions(+), 177 deletions(-) create mode 100644 crates/benilla-formats/tests/m2_uv_anim_variation.rs diff --git a/crates/benilla-app/src/pipe_warm/menagerie.rs b/crates/benilla-app/src/pipe_warm/menagerie.rs index 96ae6e1c..a88f3f6c 100644 --- a/crates/benilla-app/src/pipe_warm/menagerie.rs +++ b/crates/benilla-app/src/pipe_warm/menagerie.rs @@ -159,6 +159,10 @@ pub(super) fn spawn_menagerie( false, false, // warms the WORLD lane; the sky lane warms with its own model light, + // The pipeline menagerie warms the SHARED batch material; the + // per-placement lane (1408) builds an identical pipeline, so a clone + // needs no row of its own here. + None, )); } } @@ -198,6 +202,10 @@ pub(super) fn spawn_menagerie( false, false, // warms the WORLD lane; the sky lane warms with its own model light, + // The pipeline menagerie warms the SHARED batch material; the + // per-placement lane (1408) builds an identical pipeline, so a clone + // needs no row of its own here. + None, )); } } @@ -248,6 +256,7 @@ pub(super) fn spawn_menagerie( false, true, // the sky lane light, + None, // the shared lane — see the note above )); } } @@ -289,6 +298,7 @@ pub(super) fn spawn_menagerie( false, false, // warms the WORLD lane; the sky lane warms with its own model light, + None, // the shared lane — see the note above ); if let Some(m) = materials.get(&plain) { let mut m = m.clone(); @@ -337,6 +347,7 @@ pub(super) fn spawn_menagerie( false, false, // warms the WORLD lane; the sky lane warms with its own model light, + None, // the shared lane — see the note above ); if let Some(m) = materials.get(&h) { let mut m = m.clone(); @@ -735,7 +746,9 @@ fn warm_quad(colors: bool, skinned: bool) -> RenderSubmesh { welded_billboard: false, alpha_anim: None, uv_anim: None, + uv_seq: None, rgb_anim: None, + rgb_seq: None, wmo_batch: None, } } diff --git a/crates/benilla-assets/src/m2.rs b/crates/benilla-assets/src/m2.rs index 2b16fe63..4a94676d 100644 --- a/crates/benilla-assets/src/m2.rs +++ b/crates/benilla-assets/src/m2.rs @@ -351,7 +351,9 @@ impl AssetLoader for M2ModelLoader { billboard: billboard_info(&sub), // glow cards / chains face the camera alpha_anim: sub.alpha_anim.clone().map(std::sync::Arc::new), uv_anim: sub.uv_anim.clone().map(std::sync::Arc::new), + uv_seq: sub.uv_seq.clone().map(std::sync::Arc::new), rgb_anim: sub.rgb_anim.clone().map(std::sync::Arc::new), + rgb_seq: sub.rgb_seq.clone().map(std::sync::Arc::new), wmo_batch: None, // M2 batches have no MOBA section ground_quad: sub.ground_quad(), // flat ground-ring quads → the fx decal lane geometry: std::sync::Arc::new(sub), diff --git a/crates/benilla-assets/src/model.rs b/crates/benilla-assets/src/model.rs index 4996a733..6953285a 100644 --- a/crates/benilla-assets/src/model.rs +++ b/crates/benilla-assets/src/model.rs @@ -123,12 +123,19 @@ pub struct ModelSubmesh { /// texture transform and all WMO batches. The `Arc` doubles as the material-dedup identity: /// every instance of a loaded model shares this allocation. pub uv_anim: Option>, + /// The batch's UV loop **per file sequence slot**, `Some` only where the slots disagree — the + /// batches for which [`Self::uv_anim`]'s single loop is structurally unable to be right, + /// because which loop applies depends on the sequence the *instance* is playing (decision + /// 1408). Like [`Self::uv_anim`], the `Arc` doubles as a material-dedup identity. + pub uv_seq: Option>>, /// The batch's **animated RGB tint** (the M2Color colour track, time-varying only — a spell /// effect's white-hot flash cooling to red). When `Some`, the static vertex tint was skipped /// at parse (`benilla-formats`): the render side seeds the material's tint at the first key — /// pixel-identical to the old static bake — and the material-animating lanes tick it per /// frame. `None` for constant/keyless tints (the vertex bake). pub rgb_anim: Option>, + /// The tint twin of [`Self::uv_seq`], on the same rule. + pub rgb_seq: Option>>, /// The batch's MOBA section for WMO group batches ([`benilla_formats::WmoBatchClass`] — an /// interior group's per-batch lighting law: INT = unlit `tex × MOCV`, TRANS = the MOCV-alpha /// lit↔bake lerp, EXT = the exterior day/night law). `None` for every M2 batch. diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index f660cb9e..d746e58f 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -192,7 +192,7 @@ struct WowLight { // batch's sampled UV-scroll delta (xy, added to sun_scale.zw) or tint delta (xyz, added to // tint.rgb). Zero region = every batch at its built seed — the studio buffers and // deterministic captures ride that exactly like the tint table's zero-identity. - matanim: array, 512>, + matanim: array, 2048>, palettes: array>, }; @group(#{MATERIAL_BIND_GROUP}) @binding(90) var wow_light: WowLight; diff --git a/crates/benilla-assets/src/wmo.rs b/crates/benilla-assets/src/wmo.rs index 1611c1a1..5ac38f2d 100644 --- a/crates/benilla-assets/src/wmo.rs +++ b/crates/benilla-assets/src/wmo.rs @@ -664,7 +664,9 @@ impl AssetLoader for WmoModelLoader { billboard: None, // WMO geometry isn't billboarded alpha_anim: None, // WMO batches carry no M2 colour/weight tracks uv_anim: None, // …nor texture transforms - rgb_anim: None, // …nor M2Color tints + uv_seq: None, // …so no per-sequence set either (1408) + rgb_anim: None, + rgb_seq: None, // …nor M2Color tints wmo_batch: sub.wmo_batch, // the MOBA section — an interior group's lighting law ground_quad: None, // the fx decal lane is M2-only geometry: std::sync::Arc::new(sub), diff --git a/crates/benilla-assets/src/world_assets/mod.rs b/crates/benilla-assets/src/world_assets/mod.rs index 5d1e4243..c5fb252d 100644 --- a/crates/benilla-assets/src/world_assets/mod.rs +++ b/crates/benilla-assets/src/world_assets/mod.rs @@ -601,7 +601,13 @@ impl WorldAssets { /// `tile_radius` for the fog range. Inserted by [`AssetPlugin`] alongside [`WorldAssets`]. #[derive(Resource, Clone, Copy)] pub struct RenderConfig { - /// Tiles loaded in each direction around the player (`$WOW_TILE_RADIUS`, default 1). + /// Tiles loaded in each direction around the player (`$WOW_TILE_RADIUS`, default **2** — the + /// 5×5 block whose *worst* case (standing on a tile line) still reaches two full tiles + /// ≈ 1066 yd, clear of the 777 yd `farclip` wall, so terrain is resident before the wall + /// reveals it). `1` is the perf knob: a 3×3 block, ~2.8× smaller working set, reaching only + /// 533 yd in the worst case — the band from there to the wall has neither detailed terrain nor + /// the WDL backdrop (whose near plane sits at `farclip − 33`), so it shows only where the + /// zone's own fog end does not already hide it. pub tile_radius: u32, /// Ground-texture repeats per chunk (`$WOW_TEX_TILES`, default 8). pub tex_tiles: f32, diff --git a/crates/benilla-formats/src/bin/benilla-extract/main.rs b/crates/benilla-formats/src/bin/benilla-extract/main.rs index 470c1948..22526e1b 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/main.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/main.rs @@ -311,6 +311,41 @@ enum Command { /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. prefix: Option, }, + /// Sweep every `.m2` (optionally under a path prefix) and census the batches the bake routes + /// to a **per-placement material** — the ones whose UV loop or M2Color RGB tint loop is not the + /// same in every FILE sequence slot, so no material shared by every instance can be right for + /// them (decision 1408). The texture-transform twin of `partslotscan`. + /// + /// It asks the **bake**, not a copy of it: `RenderSubmesh::uv_seq` / `rgb_seq` are `Some` + /// exactly when `SeqLoops::uniform()` refused the shared lane, so the census and the runtime + /// cannot disagree by construction. It used to transcribe the sampler instead — it was written + /// to SIZE this fix, before the fix existed — and once the fix landed the twin drifted from it: + /// the transcription compared periods and value EXTENTS at 1e-3, which calls + /// `Spells\AdrenalineRush_Cast_Base` the same loop in both slots, where the bake's own tail key + /// differs by a full 1.0 (slot 0 ends blue, slot 1 returns to red). + /// + /// Per (batch, channel): **SHARED** — the set is `None`, the slots agree, nothing changes for + /// it (with the sub-count that actually animates on that lane) — and **PER-PLACEMENT**, the set + /// is `Some`. The latter splits by WHY the slots disagree, which is the question of whether + /// `uniform()`'s exact float equality is *earning* the routing it does: **DEAD-0** (slot 0 + /// bakes nothing while a later slot animates — the B98 shape, `BlackrockStatueLavaBubble.m2` + /// keying its whole UV flipbook inside file slot 1, a 50/50 variation of animation id 0, so the + /// slot-0 bake returns `None` and every placement is frozen for ever), **WRAP-ONLY** and + /// **KEYS-EPSILON** — the over-application buckets, where the slots hold the same authored loop + /// and are split only by the wrap flag or by sub-epsilon noise in a rebased key — and + /// **REAL-DIFFERS** (genuinely different loops, or slot 0 alive against a dead later slot). + /// Over the shipped corpus both over-application buckets are **empty**, so nothing is on the + /// per-placement lane that a coarser comparison would have left off it. + /// + /// Each bucket closes with its content family and a named worst-hit tail (batches, file + /// sequence slots). `World\` is the only family the per-placement lane reaches at all — a + /// placed doodad or WMO prop; `Creature\`/`Spells\` and the `World\Goober\` GameObject + /// displays resolve their own sequence through the entity lane — so a bucket living outside it + /// costs nothing whatever it says. + Uvslotscan { + /// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted. + prefix: Option, + }, /// Sweep every `.m2` (optionally under a path prefix) and list the models whose animation is /// authored **entirely outside the bone tracks**: sequences exist, per-sequence consumers exist /// (particle emitters, ribbons, material alpha/colour, UV transforms), and not one sequence @@ -692,6 +727,7 @@ fn main() -> Result<()> { Command::Bonescan { prefix } => scan::bonescan(&mut chain, prefix.as_deref())?, Command::Partcensus { prefix } => scan::partcensus(&mut chain, prefix.as_deref())?, Command::Partslotscan { prefix } => scan::partslotscan(&mut chain, prefix.as_deref())?, + Command::Uvslotscan { prefix } => scan::uvslotscan(&mut chain, prefix.as_deref())?, Command::Seqclockscan { prefix } => scan::seqclockscan(&mut chain, prefix.as_deref())?, Command::Uvwrapscan { prefix } => scan::uvwrapscan(&mut chain, prefix.as_deref())?, Command::Envmapscan { prefix } => scan::envmapscan(&mut chain, prefix.as_deref())?, diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs index 0b02062f..6d0bb77d 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/lighting.rs @@ -156,46 +156,6 @@ pub fn darkpropscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { Ok(()) } -/// Capitalize an `Item\ObjectComponents\` path component for consistent family-key display -/// regardless of how a given asset's listfile entry happened to be cased (`WEAPON`/`weapon`/ -/// `Weapon` all collapse to `Weapon`). -fn title_case(s: &str) -> String { - let mut chars = s.chars(); - match chars.next() { - Some(first) => { - first.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase() - } - None => String::new(), - } -} - -/// The top-level content-family bucket for an internal M2 path — `m2lightscan`'s summary -/// dimension for "how much content is affected, and of what kind". Derived from the path's first -/// one or two components, case-insensitively. `World\Goober\` (GameObject displays) splits from -/// plain `World\` (ADT-placed doodads / WMO props) because only the latter is on benilla's -/// current M2-light spawn path (`crate::terrain_stream::spawn::fx::spawn_lights_for`); everything -/// not otherwise named folds into `other`. -fn family_of(name: &str) -> String { - let comps: Vec<&str> = name.split('\\').collect(); - let low = |s: &str| s.to_ascii_lowercase(); - match comps.first().map(|s| low(s)).as_deref() { - Some("creature") => "Creature\\".to_string(), - Some("character") => "Character\\".to_string(), - Some("spells") => "Spells\\".to_string(), - Some("item") if comps.get(1).map(|s| low(s)).as_deref() == Some("objectcomponents") => { - match comps.get(2) { - Some(sub) => format!("Item\\ObjectComponents\\{}\\", title_case(sub)), - None => "Item\\ObjectComponents\\".to_string(), - } - } - Some("world") if comps.get(1).map(|s| low(s)).as_deref() == Some("goober") => { - "World\\Goober\\".to_string() - } - Some("world") => "World\\".to_string(), - _ => "other".to_string(), - } -} - /// How many rows of the closing colour tally print (the rest are counted, never silently dropped). const TALLY_ROWS: usize = 20; @@ -230,8 +190,8 @@ struct FamilyStats { /// diffuse_intensity` (raw colour, intensity, and the product), authored attenuation start/end, /// and an `OFF` tag when [`M2Light::visibility_off`] — the one shape (a static `0` visibility /// key) that keeps a light dark (§9.4). The closing summary is the real deliverable: totals, a -/// breakdown by top-level content family ([`family_of`]) — benilla only spawns these lights for -/// ADT-placed doodads and WMO props today, so this answers how much of the entity path +/// breakdown by top-level content family ([`super::family_of`]) — benilla only spawns these +/// lights for ADT-placed doodads and WMO props today, so this answers how much of the entity path /// (creatures, held items, GameObjects) is actually missing them — and a cheap diffuse /// colour×intensity tally ([`hue_tag`]). pub fn m2lightscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { @@ -265,7 +225,7 @@ pub fn m2lightscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { total_point += point_count; total_dark += dark_count; - let fam = families.entry(family_of(&name)).or_default(); + let fam = families.entry(super::family_of(&name)).or_default(); fam.models += 1; fam.point_lights += point_count; fam.dark += dark_count; diff --git a/crates/benilla-formats/src/bin/benilla-extract/scan/material.rs b/crates/benilla-formats/src/bin/benilla-extract/scan/material.rs index b6959ced..a63e6e6a 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/material.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/material.rs @@ -3,12 +3,14 @@ //! //! Blend modes (`blendscan`), per-sequence batch visibility through the alpha combine //! (`alphascan`), sampler address modes and the UVs that need them (`uvwrapscan`, `texmodescan`), -//! and generated-texcoord environment stages (`envmapscan`). +//! generated-texcoord environment stages (`envmapscan`), and the batches whose animated +//! texture-transform / tint loop is not the same in every sequence slot, which the bake routes to +//! a per-placement material (`uvslotscan`). use std::collections::BTreeMap; use anyhow::Result; -use benilla_formats::Chain; +use benilla_formats::{Chain, KeyAnim, SeqLoops}; /// Sweep every `.m2` (under `prefix`, if given) and list the models whose MATERIAL table authors /// blend mode 5 (Mod) / 6 (Mod2x) — the multiply-blend census (decision 0528). One line per @@ -396,3 +398,490 @@ pub fn envmapscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { ); Ok(()) } + +/// The two per-batch channels the bake resolves **per file sequence slot** — the pair +/// `models::m2_batches` hands the runtime as `uv_seq` / `rgb_seq`. Both are `C3Vector` tracks, so +/// one classifier serves both; only how many components survive the bake differs. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Channel { + /// The texture transform's **translation** track (`tex_anim::bake_uv_seqs`), read off + /// `RenderSubmesh::uv_seq` and its shared-lane twin `uv_anim`. + Uv, + /// The M2Color **RGB** tint track (`mat_anim::bake_rgb_seqs`), read off `rgb_seq` / `rgb_anim`. + Rgb, +} + +impl Channel { + fn label(self) -> &'static str { + match self { + Self::Uv => "UV ", + Self::Rgb => "RGB", + } + } + + fn title(self) -> &'static str { + match self { + Self::Uv => "UV (texture-transform translation)", + Self::Rgb => "RGB (M2Color tint)", + } + } + + /// The field name the report quotes, so a reader can grep the struct the numbers came from. + fn field(self) -> &'static str { + match self { + Self::Uv => "uv_seq", + Self::Rgb => "rgb_seq", + } + } + + /// Axis names for the value-range cells — one per component the bake keeps. The UV pair stays + /// `x`/`y`: the bake carries the track's raw components and how the reference maps them onto + /// U/V is still under RE (`tex_anim`'s module doc). + fn axes(self) -> &'static [&'static str] { + match self { + Self::Uv => &["x", "y"], + Self::Rgb => &["r", "g", "b"], + } + } +} + +/// How far apart two baked loops may be and still be the **same authored function**. Tight on +/// purpose: this is the noise floor of a `u32` millisecond key rebased through an `f32` divide, not +/// an authoring tolerance — a disagreement anyone could see is orders of magnitude larger. +const KEY_EPS: f32 = 1e-4; + +/// Why one batch's slots disagree — the sub-classification of every batch the bake routed to the +/// per-placement lane, i.e. of every set whose [`SeqLoops::uniform`] came back `None`. +/// +/// The question these four answer is whether that verdict is **earned**. `uniform()` compares +/// `KeyAnim` by `PartialEq` — exact float equality on the clock flags, the period and every key — +/// so two slots holding the same authored loop can still be split by a single flag or by +/// sub-epsilon noise in a rebased timestamp. `Dead0` and `RealDiffers` are real divergence, where +/// one material per placement is the only right answer; `WrapOnly` and `KeysEpsilon` are batches a +/// coarser comparison would have left on the shared lane, and their count IS the over-application. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Why { + /// Slot 0 bakes nothing while a later slot bakes a loop — the B98 shape, the founding case, and + /// the only class a slot-0-pinned bake could not render at all. + Dead0, + /// Every slot bakes the same loop except [`KeyAnim::wrap`]: the sequences carrying it disagree + /// on their own loop flag, so one band clamps at its tail where another wraps. Visible only at + /// the very end of a one-shot band — and never at all on a placed doodad, which loops. + WrapOnly, + /// Every slot agrees to within [`KEY_EPS`] on every number and on every flag — the slots differ + /// only by float noise in the rebased key times, and exact `PartialEq` is the whole reason they + /// are here. + KeysEpsilon, + /// Genuinely different loops: a different key count, a different interpolation or clock law, + /// numbers apart by more than noise — or slot 0 alive against a later slot that holds still, + /// DEAD-0's mirror. + RealDiffers, +} + +impl Why { + const ALL: [Self; 4] = [ + Self::Dead0, + Self::WrapOnly, + Self::KeysEpsilon, + Self::RealDiffers, + ]; + + fn label(self) -> &'static str { + match self { + Self::Dead0 => "DEAD-0", + Self::WrapOnly => "WRAP-ONLY", + Self::KeysEpsilon => "KEYS-EPSILON", + Self::RealDiffers => "REAL-DIFFERS", + } + } + + /// The one-line reading of the bucket, printed beside its counts. + fn blurb(self) -> &'static str { + match self { + Self::Dead0 => { + "slot 0 bakes nothing while a later slot animates — frozen for ever on the \ + shared lane, and the population the per-placement lane was built for" + } + Self::WrapOnly => { + "the slots bake the same loop and disagree only on the WRAP flag — one band \ + clamps at its tail where another wraps" + } + Self::KeysEpsilon => { + "the slots agree on every number to within 1e-4 — sub-epsilon noise in the \ + rebased keys, split by exact float equality" + } + Self::RealDiffers => { + "genuinely different loops (or slot 0 alive against a dead later slot): no single \ + shared loop can serve every sequence" + } + } + } +} + +/// One batch-channel's read of the bake. +struct Verdict { + why: Why, + /// The largest numeric disagreement between slot 0 and any other slot. `0.0` means the slots + /// are bit-identical — which, for a set the bake kept, means only a *flag* differs. + delta: f32, + /// The report line's detail cell. + detail: String, +} + +/// The largest absolute disagreement between two baked loops over everything a consumer reads: the +/// period, and every key's time and value. `None` when they are not the same **shape** at all — a +/// different key count or a different interpolation/clock law is a different function, not noise. +fn loop_delta>(a: &KeyAnim, b: &KeyAnim) -> Option { + if a.step != b.step || a.gseq != b.gseq || a.keys.len() != b.keys.len() { + return None; + } + let mut d = (a.period - b.period).abs(); + for ((ta, va), (tb, vb)) in a.keys.iter().zip(&b.keys) { + d = d.max((ta - tb).abs()); + for (x, y) in va.as_ref().iter().zip(vb.as_ref()) { + d = d.max((x - y).abs()); + } + } + Some(d) +} + +/// `[.L]` — one character per **file** sequence slot: `L` where the slot bakes a loop, `.` where +/// its key window moves nothing. Slot 0 is leftmost, so a leading `.` reads as the B98 shape at a +/// glance. Long tables (a creature's fifty-odd sequences) are truncated to a readable head. +fn slot_cell(slots: &[Option>]) -> String { + const HEAD: usize = 28; + let bits: String = slots + .iter() + .map(|s| if s.is_some() { 'L' } else { '.' }) + .collect(); + let live = slots.iter().filter(|s| s.is_some()).count(); + if bits.len() <= HEAD { + format!("[{bits}]") + } else { + format!("[{}…] {live}/{} live", &bits[..HEAD], slots.len()) + } +} + +/// `[Wc.]` — the per-slot clock law, the other half of what `PartialEq` compares: `W` wrap, `c` +/// clamp, `.` dead. The whole content of a WRAP-ONLY row. +fn wrap_cell(slots: &[Option>]) -> String { + slots + .iter() + .map(|s| match s { + Some(l) if l.wrap => 'W', + Some(_) => 'c', + None => '.', + }) + .collect::() +} + +/// `1.500s wrap 28k x[+0.000..+0.938] y[…]` — one baked loop as its consumer sees it: the clock, +/// the size, and the range it sweeps on every component the bake keeps. +fn loop_cell>(l: &KeyAnim, ch: Channel) -> String { + let range = ch + .axes() + .iter() + .enumerate() + .map(|(c, axis)| { + let (lo, hi) = l + .keys + .iter() + .fold((f32::MAX, f32::MIN), |(lo, hi), (_, v)| { + let x = v.as_ref()[c]; + (lo.min(x), hi.max(x)) + }); + format!("{axis}[{lo:+.3}..{hi:+.3}]") + }) + .collect::>() + .join(" "); + format!( + "{:>6.3}s {} {:>3}k {range}", + l.period, + if l.wrap { "wrap " } else { "clamp" }, + l.keys.len(), + ) +} + +/// Read one batch-channel's per-slot set exactly as the runtime does — [`SeqLoops::slots`], the +/// same values [`SeqLoops::uniform`] compared — and say why they disagree. +fn classify + PartialEq>(set: &SeqLoops, ch: Channel) -> Verdict { + let slots = set.slots(); + let cell = slot_cell(slots); + // A set exists only when SOME slot bakes, so a dead slot 0 is by construction a dead slot 0 + // beside a live later one — no second test needed. + let Some(base) = slots.first().and_then(Option::as_ref) else { + let (s, live) = slots + .iter() + .enumerate() + .find_map(|(s, l)| Some((s, l.as_ref()?))) + .expect("a set exists only when some slot bakes a loop"); + return Verdict { + why: Why::Dead0, + delta: 0.0, + detail: format!("{cell} slot 0 dead, slot {s} {}", loop_cell(live, ch)), + }; + }; + let (mut delta, mut wrap) = (0.0f32, false); + for (s, slot) in slots.iter().enumerate().skip(1) { + let Some(l) = slot.as_ref() else { + return Verdict { + why: Why::RealDiffers, + delta, + detail: format!("{cell} slot 0 {} vs slot {s} dead", loop_cell(base, ch),), + }; + }; + // Name WHAT diverges, not merely that something did: a value-range cell hides a timing + // difference (same extremes, different key times) behind identical-looking brackets, and + // that is exactly the ambiguity this scan exists to remove. + let reason = match loop_delta(base, l) { + Some(d) if d <= KEY_EPS => { + delta = delta.max(d); + wrap |= l.wrap != base.wrap; + continue; + } + Some(d) => format!("keys apart by {d:.1e}"), + None => "a different key count or clock law".to_string(), + }; + return Verdict { + why: Why::RealDiffers, + delta, + detail: format!( + "{cell} slot 0 {} vs slot {s} {} — {reason}", + loop_cell(base, ch), + loop_cell(l, ch), + ), + }; + } + let why = if wrap { + Why::WrapOnly + } else { + Why::KeysEpsilon + }; + Verdict { + why, + delta, + detail: format!( + "{cell} {} clock {} delta {delta:.1e}", + loop_cell(base, ch), + wrap_cell(slots), + ), + } +} + +/// Per-channel corpus counters. Model counts are per bucket and count a model once if **any** of +/// its batches lands there, so they overlap rather than partition. +#[derive(Default)] +struct Tally { + /// The set is `None`: every slot bakes the same loop, so the batch keeps the shared lane. + shared_b: u32, + /// …and of those, the ones that actually animate there (`uv_anim`/`rgb_anim` with `period > 0`). + shared_live_b: u32, + shared_live_m: u32, + /// The set is `Some` — batches and models routed to the per-placement lane, by [`Why`]. + per_b: [u32; 4], + per_m: [u32; 4], + /// Models with at least one per-placement batch on this channel, whatever the reason. + per_m_any: u32, + /// WRAP-ONLY batches whose slots are **bit-identical** apart from the flag; the rest carry + /// sub-epsilon key noise on top of it. + wrap_exact: u32, +} + +/// One model's per-placement batches in one (channel, bucket) — the row the family census and the +/// worst-hit tail are both built from. +struct Hit { + name: String, + ch: Channel, + why: Why, + batches: u32, + /// The model's **file** sequence slot count, straight off the set the bake built. + slots: usize, +} + +/// How many rows of the worst-hit tail print (the rest are counted, never silently dropped). +const TAIL_ROWS: usize = 25; + +/// Sweep every `.m2` (optionally under a path prefix) and census the batches the bake routes to a +/// **per-placement material** because their UV / tint loop is not the same in every file sequence +/// slot — see the `Uvslotscan` command doc for the why. +/// +/// It reads the bake's own verdict (`RenderSubmesh::uv_seq` / `rgb_seq`, `Some` exactly when +/// [`SeqLoops::uniform`] refused the shared lane) rather than re-deriving it: this scan used to +/// carry a transcribed sampler because the fix it was sizing did not exist yet, and once it did the +/// twin drifted from it — the very failure `idleslotscan`'s rule names ("beside the parse, so the +/// census asks the renderer's own question instead of a hand-copied twin that can drift from it"). +/// Every number below is therefore what the runtime sees, and the four PER-PLACEMENT buckets say +/// how much of it `uniform()`'s exact float equality is *earning*. +pub fn uvslotscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> { + let names = super::m2_names(chain, prefix)?; + let (mut scanned, mut batches) = (0u32, 0u64); + let mut tally: [Tally; 2] = Default::default(); + let mut hits: Vec = Vec::new(); + + for name in names { + let Ok(bytes) = chain.read_file(&name) else { + continue; + }; + let dir = name.rsplit_once('\\').map(|(d, _)| d).unwrap_or(""); + let Ok(subs) = benilla_formats::parse_m2_render_submeshes(&bytes, dir, &[]) else { + continue; + }; + scanned += 1; + batches += subs.len() as u64; + + let mut lines: Vec = Vec::new(); + // This model's own contribution, folded into the corpus tally once per channel below: + // (shared, shared-and-live) batch counts, per-bucket batch counts, and the file sequence + // slot count — which only a batch carrying a set can report, because the set IS the table. + let mut shared = [(0u32, 0u32); 2]; + let mut per = [[0u32; 4]; 2]; + let mut slots = [0usize; 2]; + for (bi, sub) in subs.iter().enumerate() { + for ch in [Channel::Uv, Channel::Rgb] { + let (verdict, live, n) = match ch { + Channel::Uv => ( + sub.uv_seq.as_ref().map(|s| classify(s, ch)), + sub.uv_anim.as_ref().is_some_and(|a| a.period > 0.0), + sub.uv_seq.as_ref().map_or(0, |s| s.slots().len()), + ), + Channel::Rgb => ( + sub.rgb_seq.as_ref().map(|s| classify(s, ch)), + sub.rgb_anim.as_ref().is_some_and(|a| a.period > 0.0), + sub.rgb_seq.as_ref().map_or(0, |s| s.slots().len()), + ), + }; + let c = ch as usize; + let Some(v) = verdict else { + shared[c].0 += 1; + shared[c].1 += u32::from(live); + continue; + }; + per[c][v.why as usize] += 1; + slots[c] = n; + if v.why == Why::WrapOnly && v.delta == 0.0 { + tally[c].wrap_exact += 1; + } + lines.push(format!( + " {} batch {bi:>3} {:<12} {}", + ch.label(), + v.why.label(), + v.detail + )); + } + } + for ch in [Channel::Uv, Channel::Rgb] { + let c = ch as usize; + let t = &mut tally[c]; + t.shared_b += shared[c].0; + t.shared_live_b += shared[c].1; + t.shared_live_m += u32::from(shared[c].1 > 0); + t.per_m_any += u32::from(per[c].iter().any(|&n| n > 0)); + for w in Why::ALL { + let n = per[c][w as usize]; + if n > 0 { + t.per_b[w as usize] += n; + t.per_m[w as usize] += 1; + hits.push(Hit { + name: name.clone(), + ch, + why: w, + batches: n, + slots: slots[c], + }); + } + } + } + if !lines.is_empty() { + println!( + "{name} ({} file sequence slots)", + slots.iter().copied().max().unwrap_or(0) + ); + for l in &lines { + println!("{l}"); + } + } + } + + println!(); + println!("=== summary === {scanned} model(s) parsed, {batches} render batch(es)"); + for ch in [Channel::Uv, Channel::Rgb] { + let t = &tally[ch as usize]; + let per_total: u32 = t.per_b.iter().sum(); + println!(); + println!( + "=== {} === {} batch-channel(s)", + ch.title(), + u64::from(t.shared_b) + u64::from(per_total) + ); + println!( + " SHARED {:>7} batch(es) — `{}` is None: every slot bakes the same loop, so \ + the batch keeps the per-material lane it has always had", + t.shared_b, + ch.field() + ); + println!( + " of those {:>7} batch(es) / {:>4} model(s) carry a LIVE loop there \ + (period > 0); the rest animate nothing at all", + t.shared_live_b, t.shared_live_m + ); + println!( + " PER-PLACEMENT {:>7} batch(es) / {:>4} model(s) — `{}` is Some: `uniform()` \ + refused the shared lane, so the world streamer builds this batch a material per \ + placement", + per_total, + t.per_m_any, + ch.field() + ); + for w in Why::ALL { + println!( + " {:<13} {:>5} batch(es) / {:>4} model(s) — {}", + w.label(), + t.per_b[w as usize], + t.per_m[w as usize], + w.blurb() + ); + } + if t.per_b[Why::WrapOnly as usize] > 0 { + println!( + " ({} of the WRAP-ONLY batches are bit-identical apart from the flag; the \ + rest carry sub-epsilon key noise on top of it)", + t.wrap_exact + ); + } + // Where each bucket's population LIVES, and which models carry most of it. `World\` is a + // placed doodad or WMO prop — the only lane the per-placement material reaches — so a + // bucket that is mostly `Creature\`/`Spells\` costs nothing today whatever it says. + for w in Why::ALL { + let rows: Vec<&Hit> = hits + .iter() + .filter(|h| h.ch == ch && h.why == w) + .collect::>(); + if rows.is_empty() { + continue; + } + println!(); + println!(" --- {} --- {} model(s)", w.label(), rows.len()); + let mut fams: BTreeMap = BTreeMap::new(); + for h in &rows { + let e = fams.entry(super::family_of(&h.name)).or_default(); + e.0 += 1; + e.1 += h.batches; + } + for (fam, (m, b)) in &fams { + println!(" {fam:<28} {m:>4} model(s) {b:>5} batch(es)"); + } + let mut tail = rows; + tail.sort_by(|a, b| b.batches.cmp(&a.batches).then_with(|| a.name.cmp(&b.name))); + for h in tail.iter().take(TAIL_ROWS) { + println!( + " {:>4} batch(es) {:>3} slots {}", + h.batches, h.slots, h.name + ); + } + if let Some(rest) = tail.len().checked_sub(TAIL_ROWS).filter(|n| *n > 0) { + println!(" … and {rest} more (top {TAIL_ROWS} shown)"); + } + } + } + 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 3d200280..70661472 100644 --- a/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs +++ b/crates/benilla-formats/src/bin/benilla-extract/scan/mod.rs @@ -7,7 +7,8 @@ //! - [`world`] — placed content: a WMO root's own tables, an ADT block's placements. //! - [`lighting`] — what lights a model: the WMO prop lanes, M2 light blocks, terrain shadow. //! - [`geometry`] — what geometry a model draws: billboards, geosets, flat ground quads. -//! - [`material`] — how a batch is textured and blended: blend modes, UV wrap, env stages. +//! - [`material`] — how a batch is textured and blended: blend modes, UV wrap, env stages, +//! the batches whose UV/tint loop differs between sequence slots. //! - [`particles`] — particle and ribbon emitters, and the features the corpus authors. //! - [`skeleton`] — the bone tree and the attachment table that addresses it. //! - [`sequence`] — which sequence an arm plays, and what breaks when it is the wrong one. @@ -29,7 +30,7 @@ mod world; 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 material::{alphascan, blendscan, envmapscan, texmodescan, uvslotscan, uvwrapscan}; pub use particles::{ cellscan, fxordercensus, partcensus, partscan, partslotscan, ribbonscan, shardcensus, }; @@ -87,3 +88,48 @@ fn wmo_roots(chain: &mut Chain, prefix: Option<&str>) -> Result> { }) .collect()) } + +/// Capitalize an `Item\ObjectComponents\` path component for consistent family-key display +/// regardless of how a given asset's listfile entry happened to be cased (`WEAPON`/`weapon`/ +/// `Weapon` all collapse to `Weapon`). +fn title_case(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(first) => { + first.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase() + } + None => String::new(), + } +} + +/// The top-level content-family bucket for an internal M2 path — the summary dimension for "how +/// much content is affected, and of what KIND", shared by [`lighting::m2lightscan`] and +/// [`material::uvslotscan`]. Derived from the path's first one or two components, +/// case-insensitively. +/// +/// The split that earns its keep is `World\` versus everything else: a `World\` model is a placed +/// doodad or WMO prop, which reaches the screen through the shared-material lanes (the M2-light +/// spawn, the `UvAnimMaterials`/`MatAnimTable` registries), while a `Creature\`/`Spells\` one +/// reaches an entirely different consumer. `World\Goober\` (GameObject displays) splits off again +/// because it is the hosted lane, not the ADT/WMO one; everything not otherwise named folds into +/// `other`. +pub(super) fn family_of(name: &str) -> String { + let comps: Vec<&str> = name.split('\\').collect(); + let low = |s: &str| s.to_ascii_lowercase(); + match comps.first().map(|s| low(s)).as_deref() { + Some("creature") => "Creature\\".to_string(), + Some("character") => "Character\\".to_string(), + Some("spells") => "Spells\\".to_string(), + Some("item") if comps.get(1).map(|s| low(s)).as_deref() == Some("objectcomponents") => { + match comps.get(2) { + Some(sub) => format!("Item\\ObjectComponents\\{}\\", title_case(sub)), + None => "Item\\ObjectComponents\\".to_string(), + } + } + Some("world") if comps.get(1).map(|s| low(s)).as_deref() == Some("goober") => { + "World\\Goober\\".to_string() + } + Some("world") => "World\\".to_string(), + _ => "other".to_string(), + } +} diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 1f5fa129..5b9a92db 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -200,9 +200,9 @@ pub use models::{ 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, 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, + GlobalSeqBone, GlobalSeqChannel, GroundQuad, KeyAnim, M2AnimSummary, M2Attachment, M2Bounds, + M2Light, M2PortraitCamera, ModelAnimation, ModelBlend, ParentArm, ParentBasis, PlayableAnim, + RenderSubmesh, RgbAnim, ScalarAnim, SeqLoops, Skeleton, SkeletonBone, StringAnchors, UvAnim, WmoBatchClass, WmoDoodad, WmoDoodadSet, WmoFog, WmoGroupHeader, WmoGroupInfo, WmoLight, WmoPortalInfo, WmoPortalRef, WmoPortals, WmoRoot, NO_GROUP_LIQUID, OWNER_RUNG_BUCKETS, }; diff --git a/crates/benilla-formats/src/models/key_anim.rs b/crates/benilla-formats/src/models/key_anim.rs index 6a419129..7537d8e3 100644 --- a/crates/benilla-formats/src/models/key_anim.rs +++ b/crates/benilla-formats/src/models/key_anim.rs @@ -219,6 +219,68 @@ pub(super) fn sample_window( /// `gseq_durations` is the model's global-sequence table (ms); `seq` the sequence to bake for /// (see [`SeqSlot`]). A `gseq`-tagged track ignores `seq` entirely — it runs on the global /// sequence's own free clock, the same loop in every animation. +/// One baked loop **per file sequence slot** — the shape a channel needs when its track is keyed +/// differently in different sequences, which the reference reads as a matter of course (it samples +/// the *playing* sequence's own key window every frame, `ranges[seqSlot]`; wow-re `eval.md` FN1 +/// `0x713d50`) and benilla's UV/tint lanes did not. +/// +/// Its whole reason to exist is [`Self::uniform`]. The UV and tint loops are consumed through a +/// registry keyed by MATERIAL, which is shared by every instance of a batch and so has no sequence +/// to key on — a design that is exactly right while every slot bakes the same loop, and silently +/// wrong when they don't. `uniform()` is the question "may this batch keep the shared lane?", asked +/// once at bake time instead of assumed: `Some` ⇒ yes, and the consumer carries one loop as before; +/// `None` ⇒ the batch's animation depends on which sequence its instance is playing, so it needs a +/// per-instance consumer (decision 1408). +/// +/// The corpus population is small and real (`benilla-extract uvslotscan`): 22 of the 32 multi-slot +/// UV batch-channels ship a **dead slot 0** beside a live later slot — the BRM lava bubbles keying +/// their whole flipbook inside the 50 %-weighted variation 1, bug B98. +#[derive(Clone, Debug, PartialEq)] +pub struct SeqLoops { + /// One entry per **file** sequence slot, `None` where that slot's key window moves nothing. + per_seq: Vec>>, +} + +impl SeqLoops { + /// Build from one entry per file sequence slot; `None` when no slot animates at all (the + /// overwhelming majority — the caller then carries no set). + pub fn new(per_seq: Vec>>) -> Option { + per_seq + .iter() + .any(Option::is_some) + .then_some(Self { per_seq }) + } + + /// The one loop every slot bakes to, or `None` when the slots disagree — including the + /// asymmetric case a dead slot 0 makes (`None` in one slot, a loop in another), which is the + /// whole population this type was added for. + pub fn uniform(&self) -> Option<&KeyAnim> { + let first = self.per_seq.first()?; + self.per_seq + .iter() + .all(|s| s == first) + .then_some(first.as_ref()) + .flatten() + } + + /// This batch's loop in sequence slot `seq` — slot 0 when unknown or out of range, the same + /// degrade [`super::mat_anim::AlphaAnim::seq`] makes and for the same reason (a consumer with + /// no live sequence is one that arms slot 0 and never leaves it). + pub fn seq(&self, seq: Option) -> Option<&KeyAnim> { + seq.and_then(|i| self.per_seq.get(i)) + .or_else(|| self.per_seq.first())? + .as_ref() + } + + /// Every slot's loop, file order — the census instrument's read + /// (`benilla-extract uvslotscan`), which asks this type rather than a hand-rolled twin of the + /// sampler (the `idleslotscan` rule: an instrument that re-implements the gate can drift from + /// it). + pub fn slots(&self) -> &[Option>] { + &self.per_seq + } +} + pub(crate) fn bake_track( track: &M2Track, gseq_durations: &[u32], @@ -313,3 +375,70 @@ pub(crate) fn bake_track( keys: baked, }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn loop_of(period: f32, keys: &[(f32, f32)]) -> KeyAnim { + KeyAnim { + period, + step: false, + wrap: true, + gseq: false, + keys: keys.to_vec(), + } + } + + /// The whole point of the type: **an asymmetric pair is not uniform.** A dead slot 0 beside a + /// live slot 1 is the shape 22 of the corpus's 32 multi-slot UV batch-channels ship, and the + /// shared-material lane must be refused for it — a `uniform()` that only compared the *present* + /// loops would wave the BRM lava bubbles straight back through (bug B98). + #[test] + fn a_dead_slot_zero_beside_a_live_slot_is_not_uniform() { + let set = SeqLoops::new(vec![None, Some(loop_of(3.3, &[(0.0, 0.0), (1.0, 0.6)]))]) + .expect("one slot animates"); + assert!(set.uniform().is_none()); + assert!(set.seq(Some(0)).is_none(), "slot 0 is the dead hold"); + assert!(set.seq(Some(1)).is_some(), "slot 1 is the animation"); + } + + /// The population the shared lane was built for, and keeps: every slot the same loop. `uniform` + /// hands back that loop, so the caller carries one and nothing changes. + #[test] + fn matching_slots_collapse_to_the_one_shared_loop() { + let l = loop_of(1.0, &[(0.0, 0.0), (1.0, 1.0)]); + let set = SeqLoops::new(vec![Some(l.clone()), Some(l.clone())]).expect("animates"); + assert_eq!(set.uniform(), Some(&l)); + } + + /// Two loops that differ are refused even though both are live — the DIFFERS class, where the + /// slot-0 pin renders a real but *wrong* animation rather than a frozen one + /// (`Deterrence_State_Base` tints red→blue in Stand and green→red in Hold). + #[test] + fn differing_live_slots_are_not_uniform_either() { + let set = SeqLoops::new(vec![ + Some(loop_of(1.0, &[(0.0, 0.0), (1.0, 1.0)])), + Some(loop_of(2.0, &[(0.0, 1.0), (2.0, 0.0)])), + ]) + .expect("animates"); + assert!(set.uniform().is_none()); + } + + /// Nothing anywhere ⇒ no set at all: the caller carries nothing, as it always has for the + /// ~98.6 % of batches with no texture transform. + #[test] + fn a_set_with_no_live_slot_is_no_set() { + assert!(SeqLoops::::new(vec![None, None]).is_none()); + } + + /// An out-of-range or unknown sequence degrades to slot 0 — the same rule `AlphaAnim::seq` + /// makes, so a consumer with no live sequence reads what a slot-0-arming consumer would. + #[test] + fn an_unknown_sequence_degrades_to_slot_zero() { + let l = loop_of(1.0, &[(0.0, 0.0), (1.0, 1.0)]); + let set = SeqLoops::new(vec![Some(l.clone()), None]).expect("animates"); + assert_eq!(set.seq(None), Some(&l)); + assert_eq!(set.seq(Some(9)), Some(&l)); + } +} diff --git a/crates/benilla-formats/src/models/m2_batches.rs b/crates/benilla-formats/src/models/m2_batches.rs index f58c71eb..5ec21785 100644 --- a/crates/benilla-formats/src/models/m2_batches.rs +++ b/crates/benilla-formats/src/models/m2_batches.rs @@ -568,13 +568,22 @@ pub fn parse_m2_render_submeshes( // The batch's UV-animation loop (decision 0130 phase 3): batch combo → texAnimLookup → // translation track, on the same clocks. Carried on the submesh; the renderer applies it. let uv_anim = tex_anim::bake_uv_anim(model, batch.texture_transform_combo_index, seq0_slot); + // …and the per-sequence set, carried ONLY when the slots disagree — the shared-material + // registry cannot key on a sequence, so a batch whose loop depends on which one is playing + // needs a per-instance consumer instead (decision 1408). `uniform()` asks that once here + // rather than leaving it assumed; every batch whose slots agree keeps `uv_anim` alone and + // the lane it has always taken. + let uv_seq = tex_anim::bake_uv_seqs(model, batch.texture_transform_combo_index, &seq_slots) + .filter(|set| set.uniform().is_none()); // The batch's animated RGB tint (the M2Color colour track's runtime half): only a // time-varying track bakes — a `Some` here *replaces* the static vertex tint below (a // spell effect's white-hot flash cooling to red would otherwise freeze on its first key). - let rgb_anim = model - .color_rgb_tracks - .get(batch.color_index as usize) - .and_then(|t| mat_anim::bake_rgb_anim(t, &model.global_sequences, seq0_slot)); + let rgb_track = model.color_rgb_tracks.get(batch.color_index as usize); + let rgb_anim = + rgb_track.and_then(|t| mat_anim::bake_rgb_anim(t, &model.global_sequences, seq0_slot)); + let rgb_seq = rgb_track + .and_then(|t| mat_anim::bake_rgb_seqs(t, &model.global_sequences, &seq_slots)) + .filter(|set| set.uniform().is_none()); // M2 billboard bones: a batch's vertices ride billboard bones (glow cards, chains) that the // real client re-orients to the camera each frame — **each bone independently, about its own // pivot**. One batch often packs several such cards: a candelabra's candle glows share a single @@ -706,7 +715,9 @@ pub fn parse_m2_render_submeshes( } sub.alpha_anim = alpha_anim.clone(); // every billboard-split group shares the batch's loops sub.uv_anim = uv_anim.clone(); + sub.uv_seq = uv_seq.clone(); sub.rgb_anim = rgb_anim.clone(); + sub.rgb_seq = rgb_seq.clone(); out.push(sub); } } diff --git a/crates/benilla-formats/src/models/mat_anim.rs b/crates/benilla-formats/src/models/mat_anim.rs index e581b197..df960962 100644 --- a/crates/benilla-formats/src/models/mat_anim.rs +++ b/crates/benilla-formats/src/models/mat_anim.rs @@ -17,7 +17,7 @@ use benilla_m2::{M2ScalarTrack, M2Vec3Track}; -use super::key_anim::{bake_track, KeyAnim, SeqSlot}; +use super::key_anim::{bake_track, KeyAnim, SeqLoops, SeqSlot}; /// One baked scalar loop, seconds — see [`KeyAnim`]. The alpha channels' instantiation. pub type ScalarAnim = KeyAnim; @@ -159,6 +159,24 @@ pub(super) fn bake_rgb_anim( bake_track(track, gseq_durations, seq, |v| v, |_| true, |_| true) } +/// The per-sequence form of [`bake_rgb_anim`] — same shape, same reason as +/// [`super::tex_anim::bake_uv_seqs`]. The tint channel is pinned to slot 0 by the same line and +/// breaks the same way: `uvslotscan` finds `Spells\\Deterrence_State_Base.m2` tinting +/// **red→blue in Stand and green→red in Hold**, so the slot-0 pin renders a *wrong colour* there, +/// not merely a frozen one (decision 1408). +pub(super) fn bake_rgb_seqs( + track: &M2Vec3Track, + gseq_durations: &[u32], + slots: &[SeqSlot], +) -> Option> { + SeqLoops::new( + slots + .iter() + .map(|&slot| bake_track(track, gseq_durations, Some(slot), |v| v, |_| true, |_| true)) + .collect(), + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla-formats/src/models/mod.rs b/crates/benilla-formats/src/models/mod.rs index 7a38b784..1b29aa2d 100644 --- a/crates/benilla-formats/src/models/mod.rs +++ b/crates/benilla-formats/src/models/mod.rs @@ -34,6 +34,7 @@ pub use anim_summary::*; pub use bounds::*; pub use collision::*; pub(crate) use key_anim::{bake_track, SeqSlot}; +pub use key_anim::{KeyAnim, SeqLoops}; pub use m2_batches::*; pub use mat_anim::{AlphaAnim, AlphaSeq, RgbAnim, ScalarAnim}; pub use records::*; @@ -102,7 +103,9 @@ fn remap_submesh( welded_billboard: false, // set by the M2 path from the separability gate (0839) alpha_anim: None, // set by the M2 path from the batch's colour/weight tracks uv_anim: None, // set by the M2 path from the batch's texture transform + uv_seq: None, // …and its per-sequence set, when the slots disagree (1408) rgb_anim: None, // set by the M2 path from the batch's colour RGB track + rgb_seq: None, // …ditto (1408) wmo_batch: None, // set by the WMO path from the MOGP batch-section counts env_map: false, // set by the M2 path from texture_unit_lookup[texCoordSet] > 2 }, diff --git a/crates/benilla-formats/src/models/tex_anim.rs b/crates/benilla-formats/src/models/tex_anim.rs index da147258..3a5e74a0 100644 --- a/crates/benilla-formats/src/models/tex_anim.rs +++ b/crates/benilla-formats/src/models/tex_anim.rs @@ -12,7 +12,7 @@ use benilla_m2::M2Model; -use super::key_anim::{bake_track, KeyAnim, SeqSlot}; +use super::key_anim::{bake_track, KeyAnim, SeqLoops, SeqSlot}; /// One baked UV-offset loop, seconds — see [`KeyAnim`]. The texture-transform translation /// channel's instantiation: values are the track's raw `(x, y)`. @@ -57,6 +57,39 @@ pub(super) fn bake_uv_anim( ) } +/// Bake the batch's UV-offset loop **for every file sequence slot** — the per-sequence form of +/// [`bake_uv_anim`], and the answer to the question that function's own doc used to settle by +/// measurement ("every UV-animating model measured is a scroll that runs the same in every +/// sequence"). `benilla-extract uvslotscan` re-ran that measurement and it is false: 22 of the 32 +/// multi-slot UV batch-channels in the 1.12 corpus have a **dead slot 0** beside a live later slot. +/// +/// `None` when no slot animates at all. The caller keeps [`bake_uv_anim`]'s slot-0 loop for the +/// shared-material lane and reaches for this only when [`SeqLoops::uniform`] declines (decision +/// 1408). +pub(super) fn bake_uv_seqs( + model: &M2Model, + combo_index: u16, + slots: &[SeqSlot], +) -> Option> { + let ti = *model.texture_transform_lookup.get(combo_index as usize)?; + let t = model.texture_transforms.get(ti as usize)?; + SeqLoops::new( + slots + .iter() + .map(|&slot| { + bake_track( + &t.translation, + &model.global_sequences, + Some(slot), + |v| [v[0], v[1]], + is_zero, + is_zero, + ) + }) + .collect(), + ) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla-formats/src/models/types.rs b/crates/benilla-formats/src/models/types.rs index 0bdf7b0f..1c9fc1f2 100644 --- a/crates/benilla-formats/src/models/types.rs +++ b/crates/benilla-formats/src/models/types.rs @@ -4,6 +4,7 @@ //! ([`BoneScaleAnim`]). No parsing lives here — just the shapes [`m2_batches`](super::m2_batches) and //! [`wmo`](super::wmo) both build. +use super::key_anim::SeqLoops; use super::mat_anim::{AlphaAnim, RgbAnim}; use super::tex_anim::UvAnim; @@ -479,6 +480,13 @@ pub struct RenderSubmesh { /// translation track baked by [`tex_anim`](super::tex_anim) — the raw `(x, y)` offset over the loop /// clock. `None` for the ~98.6% of models with no texture transform, and for all WMO batches. pub uv_anim: Option, + /// The batch's UV loop **per file sequence slot**, carried ONLY when the slots disagree — i.e. + /// when [`Self::uv_anim`]'s single loop cannot be right for every instance, because which loop + /// applies depends on which sequence that instance is playing (decision 1408, bug B98: the BRM + /// lava bubbles key their whole flipbook inside the 50 %-weighted variation 1, so slot 0 — the + /// only slot the shared-material registry can read — is a dead hold). `None` for every batch + /// whose slots agree, which is the shared lane unchanged. + pub uv_seq: Option>, /// The batch's **animated RGB tint** (the M2Color colour track, time-varying only — a spell /// effect's white-hot flash cooling to red): baked by [`mat_anim`](super::mat_anim). When /// `Some`, the static vertex-colour tint is **skipped** for this batch (the two would @@ -486,6 +494,8 @@ pub struct RenderSubmesh { /// key and animated where the lane runs material animation. `None` for constant/keyless tints /// (the static vertex bake, decision 0029) — the overwhelming majority. pub rgb_anim: Option, + /// The tint twin of [`Self::uv_seq`], on the same rule and for the same reason. + pub rgb_seq: Option>, /// The batch's MOBA section for WMO group batches ([`WmoBatchClass`] — TRANS / INT / EXT, the /// per-class lighting law of an interior group). `None` for every M2 batch. pub wmo_batch: Option, @@ -586,7 +596,9 @@ impl Default for RenderSubmesh { welded_billboard: false, alpha_anim: None, uv_anim: None, + uv_seq: None, rgb_anim: None, + rgb_seq: None, wmo_batch: None, env_map: false, } diff --git a/crates/benilla-formats/tests/m2_billboard_card_face.rs b/crates/benilla-formats/tests/m2_billboard_card_face.rs index a4f033b6..2c28624e 100644 --- a/crates/benilla-formats/tests/m2_billboard_card_face.rs +++ b/crates/benilla-formats/tests/m2_billboard_card_face.rs @@ -119,7 +119,9 @@ fn the_shape_ignores_edge_on_correct_and_degenerate_batches() { welded_billboard: false, alpha_anim: None, uv_anim: None, + uv_seq: None, rgb_anim: None, + rgb_seq: None, wmo_batch: None, env_map: false, }; diff --git a/crates/benilla-formats/tests/m2_uv_anim_variation.rs b/crates/benilla-formats/tests/m2_uv_anim_variation.rs new file mode 100644 index 00000000..34ee1eda --- /dev/null +++ b/crates/benilla-formats/tests/m2_uv_anim_variation.rs @@ -0,0 +1,98 @@ +//! The BRM lava bubbles' animation is frozen (bug B98, decision 1408) — and the asset says why in +//! one number: **every one of this model's animated channels is keyed inside file sequence slot 1, +//! and slot 1 is a 50 %-weighted variation of animation id 0.** +//! +//! `BlackrockStatueLavaBubble.m2` is placed 15 times inside `Blackrock.wmo`. It authors two +//! sequences, both animation id 0 with frequencies 16384/16383 — so the reference re-rolls between +//! them every play-window (wow-re `doodad-anim-host.md` §5, decision 0768) and two placements are +//! on different slots at the same instant. Slot 0 is a dead hold: its bone-scale window is two +//! identity keys and its texture-transform window is two zero keys. Slot 1 is the whole animation: +//! the bubbles swell 1.0 → 2.785 while the sprite's V offset flips by 0.605. +//! +//! benilla baked the UV loop against slot 0 and only slot 0, through a registry keyed by MATERIAL — +//! shared by every instance of the batch, so there is no sequence to key on. On this model that +//! bakes to `None`: no registration, no table row, no scroll, on every placement, for ever. This +//! file pins both halves of that — the dead slot 0 and the live slot 1 — so the per-sequence set +//! can never silently collapse back to one loop. +//! +//! Skips (passes) when the client isn't present at `/WoW/Data`. + +use benilla_formats::{open_chain, parse_m2_animations, parse_m2_render_submeshes}; + +const BUBBLE: &str = + "World\\KhazModan\\Blackrock\\PassiveDoodads\\BlackrockLavaBubbles\\BlackrockStatueLavaBubble.m2"; + +#[test] +fn the_lava_bubble_keys_its_whole_uv_flipbook_in_variation_one() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = open_chain(&data).expect("open vanilla patch chain"); + let bytes = chain + .read_file(BUBBLE) + .expect("the bubble M2 is in the chain"); + + // 1. Two sequences, both animation id 0 — a variation chain, not a state machine. The roll is + // per placement and per window, so no single slot can be "the" one to bake. + let anims = parse_m2_animations(&bytes); + assert_eq!(anims.len(), 2, "two sequences"); + assert!( + anims.iter().all(|a| a.anim_id == 0), + "both are animation id 0 — one variation chain" + ); + assert!( + anims.iter().all(|a| a.frequency > 15_000), + "…and both are genuinely rollable (≈50/50), so placements DIVERGE: {:?}", + anims.iter().map(|a| a.frequency).collect::>() + ); + + // 2. The bones say it first: slot 0 holds bind pose, slot 1 swells past 2.7×. + let peak = |slot: usize| { + anims[slot] + .bones + .iter() + .flat_map(|b| b.scale.iter().map(|&(_, s)| s[0])) + .fold(0.0f32, f32::max) + }; + assert!((peak(0) - 1.0).abs() < 1e-3, "slot 0 is a rest hold"); + assert!(peak(1) > 2.5, "slot 1 is the swell (peak {})", peak(1)); + + // 3. …and the UV channel agrees, which is the half benilla renders. Every batch carries a + // per-sequence set — the bake's own "the slots disagree, this batch cannot take the shared + // lane" verdict — whose slot 0 is empty and whose slot 1 moves. + let subs = parse_m2_render_submeshes(&bytes, "", &[]).expect("parse"); + assert_eq!(subs.len(), 5, "five bubbles, five batches"); + for (i, sub) in subs.iter().enumerate() { + let set = sub + .uv_seq + .as_ref() + .unwrap_or_else(|| panic!("batch {i} carries a per-sequence UV set")); + assert!( + set.uniform().is_none(), + "batch {i}: the slots disagree — that IS the verdict" + ); + assert!( + set.slots()[0].is_none(), + "batch {i}: slot 0's window never moves the UVs — the dead bake" + ); + let live = set.slots()[1] + .as_ref() + .unwrap_or_else(|| panic!("batch {i}: slot 1 carries the flipbook")); + let v_span = live + .keys + .iter() + .fold((f32::MAX, f32::MIN), |(lo, hi), &(_, v)| { + (lo.min(v[1]), hi.max(v[1])) + }); + assert!( + v_span.1 - v_span.0 > 0.5, + "batch {i}: the V offset flips a whole sprite row (span {:?})", + v_span + ); + } + + // 4. And the pre-1408 read — the shared lane's slot-0 bake — is `None` on every batch. This is + // the defect stated as an assertion: nothing to register, so nothing ever scrolled. + assert!( + subs.iter().all(|s| s.uv_anim.is_none()), + "the slot-0 bake yields nothing on any batch — the frozen sprite B98 reported" + ); +} diff --git a/crates/benilla-world/src/billboard.rs b/crates/benilla-world/src/billboard.rs index b0a41778..181d52a6 100644 --- a/crates/benilla-world/src/billboard.rs +++ b/crates/benilla-world/src/billboard.rs @@ -182,6 +182,10 @@ impl BillboardCard { /// (the self-avatar fade; the light node's shade push in `entity_shade`) can only recognise the /// model's own cards by testing this against the entities it walked. `None` = a fixed terrain /// doodad's card, whose shade rides its material selector instead. + /// + /// It is also the entity whose `InheritedVisibility` decides whether an entity-lane card draws + /// at all — a world root inherits nothing, so its model's hide has to be *read*, by the one + /// `Visibility` authority (`model_render::visibility`, decision 1409). pub fn follows(&self) -> Option { self.follow } @@ -523,24 +527,26 @@ pub struct BillboardPlace; /// despawned when the owner is gone (streamed out, unequipped, died). Runs in [`BillboardPlace`] /// (post-propagation), so it writes `GlobalTransform` directly alongside `Transform` — cards /// write ABSOLUTE world transforms and live at the root/identity, so the direct write is exact. -#[allow(clippy::type_complexity)] // the owner pose + visibility read, commented inline +/// +/// It does NOT own a card's `Visibility` — [`crate::model_render::visibility`] does, for every +/// card as for every other `ModelPart` (decision 0025). This system used to mirror a hidden owner +/// here as well, and that write was **dead**: [`BillboardPlace`] is only ordered +/// `before(CheckVisibility)`, not before `VisibilityPropagate`, so the `Hidden` it wrote landed +/// after the propagation that would have consumed it and was overwritten in the next frame's +/// `Update` by the authority — for a year, a GameObject's glow card went on burning over its own +/// culled model (decision 1409). +#[allow(clippy::type_complexity)] // the card tuple, commented inline pub(crate) fn face_billboards( mut commands: Commands, time: Res