the lava bubbles move again, and the bonfire keeps its wood

- 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 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-17 17:27:28 +07:00
co-authored by Claude Fable 5
parent 911f974e09
commit b932302d79
32 changed files with 1509 additions and 177 deletions
@@ -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,
}
}
+2
View File
@@ -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),
+7
View File
@@ -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<std::sync::Arc<benilla_formats::UvAnim>>,
/// 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<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
/// 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<std::sync::Arc<benilla_formats::RgbAnim>>,
/// The tint twin of [`Self::uv_seq`], on the same rule.
pub rgb_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 3]>>>,
/// 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.
@@ -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<vec4<f32>, 512>,
matanim: array<vec4<f32>, 2048>,
palettes: array<vec4<f32>>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
+3 -1
View File
@@ -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),
@@ -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,
@@ -311,6 +311,41 @@ enum Command {
/// Internal-path prefix filter (e.g. `world`), case-insensitive; all models if omitted.
prefix: Option<String>,
},
/// Sweep every `.m2` (optionally under a path prefix) and census the batches 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<String>,
},
/// 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())?,
@@ -156,46 +156,6 @@ pub fn darkpropscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
Ok(())
}
/// Capitalize an `Item\ObjectComponents\<sub>` path component for consistent family-key display
/// regardless of how a given asset's listfile entry happened to be cased (`WEAPON`/`weapon`/
/// `Weapon` all collapse to `Weapon`).
fn title_case(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(first) => {
first.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase()
}
None => String::new(),
}
}
/// The top-level content-family bucket for an internal M2 path — `m2lightscan`'s summary
/// dimension for "how much content is affected, and of what kind". Derived from the path's first
/// one or two components, case-insensitively. `World\Goober\` (GameObject displays) splits from
/// plain `World\` (ADT-placed doodads / WMO props) because only the latter is on benilla's
/// current M2-light spawn path (`crate::terrain_stream::spawn::fx::spawn_lights_for`); everything
/// not otherwise named folds into `other`.
fn family_of(name: &str) -> String {
let comps: Vec<&str> = name.split('\\').collect();
let low = |s: &str| s.to_ascii_lowercase();
match comps.first().map(|s| low(s)).as_deref() {
Some("creature") => "Creature\\".to_string(),
Some("character") => "Character\\".to_string(),
Some("spells") => "Spells\\".to_string(),
Some("item") if comps.get(1).map(|s| low(s)).as_deref() == Some("objectcomponents") => {
match comps.get(2) {
Some(sub) => format!("Item\\ObjectComponents\\{}\\", title_case(sub)),
None => "Item\\ObjectComponents\\".to_string(),
}
}
Some("world") if comps.get(1).map(|s| low(s)).as_deref() == Some("goober") => {
"World\\Goober\\".to_string()
}
Some("world") => "World\\".to_string(),
_ => "other".to_string(),
}
}
/// How many rows of the closing colour tally print (the rest are counted, never silently dropped).
const TALLY_ROWS: usize = 20;
@@ -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;
@@ -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<V: AsRef<[f32]>>(a: &KeyAnim<V>, b: &KeyAnim<V>) -> Option<f32> {
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<V>(slots: &[Option<KeyAnim<V>>]) -> 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<V>(slots: &[Option<KeyAnim<V>>]) -> String {
slots
.iter()
.map(|s| match s {
Some(l) if l.wrap => 'W',
Some(_) => 'c',
None => '.',
})
.collect::<String>()
}
/// `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<V: AsRef<[f32]>>(l: &KeyAnim<V>, 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::<Vec<_>>()
.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<V: AsRef<[f32]> + PartialEq>(set: &SeqLoops<V>, 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<Hit> = 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<String> = 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::<Vec<_>>();
if rows.is_empty() {
continue;
}
println!();
println!(" --- {} --- {} model(s)", w.label(), rows.len());
let mut fams: BTreeMap<String, (u32, u32)> = 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(())
}
@@ -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<Vec<String>> {
})
.collect())
}
/// Capitalize an `Item\ObjectComponents\<sub>` path component for consistent family-key display
/// regardless of how a given asset's listfile entry happened to be cased (`WEAPON`/`weapon`/
/// `Weapon` all collapse to `Weapon`).
fn title_case(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(first) => {
first.to_ascii_uppercase().to_string() + &chars.as_str().to_ascii_lowercase()
}
None => String::new(),
}
}
/// The top-level content-family bucket for an internal M2 path — 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(),
}
}
+3 -3
View File
@@ -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,
};
@@ -219,6 +219,68 @@ pub(super) fn sample_window<V: Lerp>(
/// `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<V> {
/// One entry per **file** sequence slot, `None` where that slot's key window moves nothing.
per_seq: Vec<Option<KeyAnim<V>>>,
}
impl<V: PartialEq> SeqLoops<V> {
/// 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<KeyAnim<V>>>) -> Option<Self> {
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<V>> {
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<usize>) -> Option<&KeyAnim<V>> {
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<KeyAnim<V>>] {
&self.per_seq
}
}
pub(crate) fn bake_track<T: Copy, V: Lerp + PartialEq>(
track: &M2Track<T>,
gseq_durations: &[u32],
@@ -313,3 +375,70 @@ pub(crate) fn bake_track<T: Copy, V: Lerp + PartialEq>(
keys: baked,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn loop_of(period: f32, keys: &[(f32, f32)]) -> KeyAnim<f32> {
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::<f32>::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));
}
}
@@ -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);
}
}
+19 -1
View File
@@ -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<f32>;
@@ -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<[f32; 3]>> {
SeqLoops::new(
slots
.iter()
.map(|&slot| bake_track(track, gseq_durations, Some(slot), |v| v, |_| true, |_| true))
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
+3
View File
@@ -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
},
+34 -1
View File
@@ -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<SeqLoops<[f32; 2]>> {
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::*;
@@ -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<UvAnim>,
/// 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<SeqLoops<[f32; 2]>>,
/// 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<RgbAnim>,
/// The tint twin of [`Self::uv_seq`], on the same rule and for the same reason.
pub rgb_seq: Option<SeqLoops<[f32; 3]>>,
/// The batch's MOBA section for WMO group batches ([`WmoBatchClass`] — TRANS / INT / EXT, the
/// per-class lighting law of an interior group). `None` for every M2 batch.
pub wmo_batch: Option<WmoBatchClass>,
@@ -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,
}
@@ -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,
};
@@ -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 `<repo>/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::<Vec<_>>()
);
// 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"
);
}
+20 -56
View File
@@ -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<Entity> {
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<Time>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
owners: Query<
(&GlobalTransform, Option<&InheritedVisibility>),
(Without<WorldCamera>, Without<BillboardCard>),
>,
owners: Query<&GlobalTransform, (Without<WorldCamera>, Without<BillboardCard>)>,
mut cards: Query<
(
Entity,
&mut BillboardCard,
&mut Transform,
&mut GlobalTransform,
&mut Visibility,
// Whether the exterior-scene cull owns this card's `Visibility` — see the mirror below.
Has<crate::exterior_cull::ExteriorScene>,
// Read for the trace probe below (the tag's low bits are the card's render alpha).
Option<&bevy::mesh::MeshTag>,
),
@@ -554,10 +560,10 @@ pub(crate) fn face_billboards(
// for every billboard; never a per-pivot aim).
let (fwd, right, up) = (*cam_tf.forward(), *cam_tf.right(), *cam_tf.up());
let elapsed_ms = time.elapsed().as_millis() as u32;
for (entity, mut card, mut tf, mut global, mut visibility, gated, tag) in &mut cards {
for (entity, mut card, mut tf, mut global, tag) in &mut cards {
if let Some(owner) = card.follow {
match owners.get(owner) {
Ok((gt, vis)) => {
Ok(gt) => {
let pivot = card.local_pivot;
card.re_place(gt.compute_transform(), pivot);
// The card-provenance trace (`WOW_MOVE_TRACE`, ~2 Hz): where each following
@@ -581,31 +587,6 @@ pub(crate) fn face_billboards(
),
);
}
// A card is visually part of its owner — mirror a HIDDEN owner, because the
// card is a world root and inherits nothing. The live case: the sea-crossing
// transport's off-map leg hides the boat subtree (`tick_transports`); without
// the mirror a deck lantern's glow keeps rendering at the other continent's
// coordinates. (The owner's inherited visibility is last propagate's — one
// frame of lag on a minutes-long hide.)
//
// **Not on a card the exterior-scene cull owns** (decision 0784). One component,
// one authority (0025): a world-placement card is tagged
// [`crate::exterior_cull::ExteriorScene`] with the rest of its model, and both
// systems run in the same unordered post-propagation window — so this write
// silently undid the cull's `Hidden` and the card drew straight through a sealed
// room's wall. Nothing is lost by standing down: such a card's owner is a joint of
// its own placement rig, which is never hidden apart from the model the cull is
// already hiding whole. The transport case above is an entity-lane card, untagged,
// and still mirrors.
if !gated {
let want = match vis {
Some(v) if !v.get() => Visibility::Hidden,
_ => Visibility::Inherited,
};
if *visibility != want {
*visibility = want;
}
}
}
Err(_) => {
commands.entity(entity).despawn();
@@ -763,27 +744,10 @@ mod tests {
Vec3::new(5.0, 1.7, 0.0),
"owner translation + authored pivot — not the model origin"
);
// The hidden-owner mirror: the card is a world root and inherits nothing, so a hidden
// owner (the sea-crossing transport's off-map leg) must hide it explicitly — and a
// re-shown owner must bring it back.
app.world_mut()
.entity_mut(owner)
.insert(InheritedVisibility::HIDDEN);
app.update();
assert_eq!(
*app.world().entity(card).get::<Visibility>().unwrap(),
Visibility::Hidden,
"a hidden owner hides its card"
);
app.world_mut()
.entity_mut(owner)
.insert(InheritedVisibility::VISIBLE);
app.update();
assert_eq!(
*app.world().entity(card).get::<Visibility>().unwrap(),
Visibility::Inherited,
"a re-shown owner restores its card"
);
// The hidden-owner half of this test used to live here, asserting a `Visibility` write
// this system made and the real schedule then threw away — a green test pinning a dead
// write (decision 1409). The law is unchanged and now lives with the one authority that
// can enforce it: `model_render::visibility::a_card_follows_its_owners_verdict`.
app.world_mut().entity_mut(owner).despawn();
app.update();
assert!(
+175 -31
View File
@@ -235,11 +235,43 @@ pub fn sample_mat_anim(
#[derive(bevy::prelude::Component)]
pub struct AnimMatPart;
/// **Which loop a registered material animates on** — and the whole of decision 1408.
///
/// A registry keyed by MATERIAL is shared by every instance of a batch, so it has no sequence to
/// key on. That is exactly right while every sequence bakes the same loop, and structurally unable
/// to be right when they don't: the BRM lava bubbles key their whole flipbook inside a 50 %-weighted
/// variation, and their 15 placements re-roll independently every ~3.3 s (decision 0768), so at any
/// instant some are on slot 0 and some on slot 1. One shared row cannot serve both.
///
/// So a batch whose slots disagree — 22 batch-channels across **6 models**, corpus-wide
/// (`benilla-extract uvslotscan`) — takes a material of its own **per placement**, keyed by its
/// anim host in [`crate::model_render::MatKey`], and this entry remembers that host so the sample
/// rides the sequence the host is actually playing. Everything else keeps the shared material and
/// the shared clock, untouched.
///
/// Per-placement materials rather than a per-instance row in the shared table: the row would have
/// to be addressed from the shader, and every per-instance channel there is spoken for
/// (`MeshTag`'s 32 bits are fully allocated; the instance slot is the lazily-allocated,
/// pressure-reaped palette slot). Against a measured population of six models it buys a new GPU
/// region and a shader branch to save a handful of draw calls on small atmospheric props, and the
/// clones are bounded by the same distance evictor every other material has (`art_scope`,
/// decision 0785). Revisit if the population ever grows.
pub enum UvLoop {
/// Every sequence bakes the same loop: the shared material, on the free-running shared clock.
Shared(std::sync::Arc<benilla_formats::UvAnim>),
/// The slots disagree: this material belongs to ONE placement, and the loop is whichever slot
/// `host` is playing right now.
PerSeq {
seqs: std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>,
host: Entity,
},
}
/// One registered UV-scroll material: its sampler, its table slot, and the built seed the delta
/// rows are measured from (the material's own `sun_scale.zw`, which is never mutated again —
/// decision 1381).
pub struct UvAnimEntry {
pub anim: std::sync::Arc<benilla_formats::UvAnim>,
pub anim: UvLoop,
pub slot: u16,
pub seed: [f32; 2],
}
@@ -249,8 +281,21 @@ impl UvAnimEntry {
/// adds it back onto `sun_scale.zw` (decision 1381's encoding). Quantized exactly as the
/// old asset-mutating lane quantized its absolute writes, so the first live frame shows the
/// same number the old path would have written.
pub(crate) fn delta(&self, now: f32) -> [f32; 4] {
let uv = self.anim.sample(now);
///
/// `playing` is the per-placement lane's live `(sequence slot, clip time)`, resolved by the
/// caller from [`Self::host`]; a host that has gone (despawned mid-frame) or a sequence with no
/// loop samples the identity, i.e. the batch sits at its built seed — the same degrade a full
/// table gives.
pub(crate) fn delta(&self, now: f32, gseq_now: f64, playing: Option<(usize, f32)>) -> [f32; 4] {
let uv = match &self.anim {
UvLoop::Shared(anim) => anim.sample(now),
UvLoop::PerSeq { seqs, .. } => playing
.and_then(|(seq, band_t)| {
seqs.seq(Some(seq))
.map(|l| l.sample(l.clock(band_t, gseq_now)))
})
.unwrap_or([0.0, 0.0]),
};
[
benilla_assets::quantize(uv[0], 4096.0) - self.seed[0],
benilla_assets::quantize(uv[1], 4096.0) - self.seed[1],
@@ -260,6 +305,37 @@ impl UvAnimEntry {
}
}
impl UvAnimEntry {
/// The placement whose sequence this entry rides, or `None` on the shared lane.
fn host(&self) -> Option<Entity> {
match &self.anim {
UvLoop::Shared(_) => None,
UvLoop::PerSeq { host, .. } => Some(*host),
}
}
}
impl TintAnimEntry {
/// [`UvAnimEntry::host`]'s twin.
fn host(&self) -> Option<Entity> {
match &self.anim {
TintLoop::Shared(_) => None,
TintLoop::PerSeq { host, .. } => Some(*host),
}
}
}
/// The anim hosts a per-placement entry reads its sequence from — [`playing_seq`] behind a query,
/// so [`tick_anim_materials`] can resolve one per entry without borrowing the world twice.
pub type SeqHosts<'w, 's> = Query<'w, 's, (&'static AnimationPlayer, &'static ModelAnimations)>;
/// The playing sequence slot + clip time of one anim host, or `None` if the entry is on the shared
/// lane, the host is gone, or it has no sequence at all.
fn host_seq(hosts: &SeqHosts, host: Option<Entity>) -> Option<(usize, f32)> {
let (player, anims) = hosts.get(host?).ok()?;
playing_seq(player, anims)
}
#[derive(Resource, Default)]
pub struct UvAnimMaterials(
pub std::collections::HashMap<
@@ -278,7 +354,7 @@ pub fn register_uv(
table: &mut crate::mat_anim_table::MatAnimTable,
materials: &mut bevy::asset::Assets<benilla_assets::materials::WowModelMaterial>,
id: bevy::asset::AssetId<benilla_assets::materials::WowModelMaterial>,
anim: &std::sync::Arc<benilla_formats::UvAnim>,
anim: UvLoop,
) {
if reg.0.contains_key(&id) {
return;
@@ -293,14 +369,14 @@ pub fn register_uv(
};
let seed = [mat.extension.sun_scale.z, mat.extension.sun_scale.w];
mat.extension.anim_slots.x = f32::from(slot);
reg.0.insert(
id,
UvAnimEntry {
anim: anim.clone(),
slot,
seed,
},
);
if let UvLoop::PerSeq { host, .. } = &anim {
// The breadcrumb for the lane that has no other tell: a per-placement material is invisible
// in every count (it is one more material, one more row), so "did the bubbles take the new
// lane at all" would otherwise be a question only the eye could answer. One line per
// registration, at debug (decision 1408).
bevy::log::debug!("mat-anim: per-placement UV lane armed for host {host} (slot {slot})");
}
reg.0.insert(id, UvAnimEntry { anim, slot, seed });
}
/// Re-sample the **drawn** animated materials on the shared clock — the UV scroll
@@ -348,6 +424,9 @@ pub(super) fn tick_anim_materials(
With<AnimMatPart>,
>,
twins: Res<crate::model_render::FarSideTwins>,
// The per-placement lane's sequence source (decision 1408): an entry registered `PerSeq` asks
// its own anim host which slot it is playing, instead of reading the shared clock.
hosts: SeqHosts,
mut drawn: Local<
bevy::platform::collections::HashSet<AssetId<benilla_assets::materials::WowModelMaterial>>,
>,
@@ -378,13 +457,17 @@ pub(super) fn tick_anim_materials(
// below what any face can show — and `MatAnimTable::set` skips same-value writes, so a slow
// loop uploads nothing most frames. Eviction is unchanged: an entry dies with its material,
// and its slot zeroes back to identity ([`crate::mat_anim_table`]'s free law).
let gseq_now = f64::from(now);
uv_reg.0.retain(|id, entry| {
if !materials.contains(*id) {
table.free(entry.slot);
return false;
}
if drawn.contains(id) {
table.set(entry.slot, entry.delta(now));
table.set(
entry.slot,
entry.delta(now, gseq_now, host_seq(&hosts, entry.host())),
);
}
true
});
@@ -394,7 +477,10 @@ pub(super) fn tick_anim_materials(
return false;
}
if drawn.contains(id) {
table.set(entry.slot, entry.delta(now));
table.set(
entry.slot,
entry.delta(now, gseq_now, host_seq(&hosts, entry.host())),
);
}
true
});
@@ -437,17 +523,40 @@ fn matanim_off(time: &Time<bevy::time::Real>) -> bool {
/// doodad's ambient loop). Spell-effect instances need real per-instance phase instead (one cast
/// = one 0.9 s pulse), so the effect lane clones its materials and ticks them on the instance
/// clock (`entities::spell_fx`), never through this registry.
/// [`UvLoop`]'s RGB twin, on the same rule and for the same reason — `uvslotscan` finds the tint
/// channel pinned to slot 0 by the same line, and `Spells\\Deterrence_State_Base.m2` tinting
/// **red→blue in Stand and green→red in Hold**, where the pin renders a *wrong colour* rather than
/// a frozen one.
pub enum TintLoop {
Shared(std::sync::Arc<benilla_formats::RgbAnim>),
PerSeq {
seqs: std::sync::Arc<benilla_formats::SeqLoops<[f32; 3]>>,
host: Entity,
},
}
/// One registered tint material — [`UvAnimEntry`]'s RGB twin (seed = the built `tint.xyz`).
pub struct TintAnimEntry {
pub anim: std::sync::Arc<benilla_formats::RgbAnim>,
pub anim: TintLoop,
pub slot: u16,
pub seed: [f32; 3],
}
impl TintAnimEntry {
/// [`UvAnimEntry::delta`]'s RGB twin (1/255 quantization, the display's own step).
pub(crate) fn delta(&self, now: f32) -> [f32; 4] {
let rgb = benilla_assets::quant255(self.anim.sample(now));
/// [`UvAnimEntry::delta`]'s RGB twin (1/255 quantization, the display's own step). The
/// per-placement lane's identity is WHITE — the tint is a multiplier, so an unresolvable host
/// must leave the batch at its built seed, not black it out.
pub(crate) fn delta(&self, now: f32, gseq_now: f64, playing: Option<(usize, f32)>) -> [f32; 4] {
let rgb = match &self.anim {
TintLoop::Shared(anim) => anim.sample(now),
TintLoop::PerSeq { seqs, .. } => playing
.and_then(|(seq, band_t)| {
seqs.seq(Some(seq))
.map(|l| l.sample(l.clock(band_t, gseq_now)))
})
.unwrap_or([1.0, 1.0, 1.0]),
};
let rgb = benilla_assets::quant255(rgb);
[
rgb[0] - self.seed[0],
rgb[1] - self.seed[1],
@@ -471,7 +580,7 @@ pub fn register_tint(
table: &mut crate::mat_anim_table::MatAnimTable,
materials: &mut bevy::asset::Assets<benilla_assets::materials::WowModelMaterial>,
id: bevy::asset::AssetId<benilla_assets::materials::WowModelMaterial>,
anim: &std::sync::Arc<benilla_formats::RgbAnim>,
anim: TintLoop,
) {
if reg.0.contains_key(&id) {
return;
@@ -490,14 +599,7 @@ pub fn register_tint(
mat.extension.tint.z,
];
mat.extension.anim_slots.y = f32::from(slot);
reg.0.insert(
id,
TintAnimEntry {
anim: anim.clone(),
slot,
seed,
},
);
reg.0.insert(id, TintAnimEntry { anim, slot, seed });
}
#[cfg(test)]
@@ -522,12 +624,12 @@ mod delta_tests {
let anim = uv_loop();
let seed = anim.sample(0.0);
let entry = UvAnimEntry {
anim: anim.clone(),
anim: UvLoop::Shared(anim.clone()),
slot: 3,
seed,
};
for t in [0.0_f32, 0.35, 1.0, 1.7] {
let d = entry.delta(t);
let d = entry.delta(t, f64::from(t), None);
let s = anim.sample(t);
let old = [
benilla_assets::quantize(s[0], 4096.0),
@@ -548,6 +650,48 @@ mod delta_tests {
}
}
/// **B98** (decision 1408): the per-placement lane samples the slot the placement's own host is
/// playing — not slot 0, and not a shared clock.
///
/// The BRM lava bubble's shape, exactly: slot 0 bakes to nothing (a dead hold) and slot 1
/// carries the flipbook. Reading slot 0, as the shared registry must, is the frozen sprite the
/// report named; reading the host's live slot is the fix. The unresolved case — a host
/// despawned mid-frame — must land on the UV identity, i.e. the built seed, never a jump.
#[test]
fn the_per_placement_lane_reads_its_hosts_sequence() {
let seqs = std::sync::Arc::new(
benilla_formats::SeqLoops::new(vec![
None, // slot 0: the dead hold the shared lane is pinned to
Some(benilla_formats::UvAnim {
period: 4.0,
step: true,
wrap: true,
gseq: false,
keys: vec![(0.0, [0.0, 0.0]), (2.0, [0.0, 0.605])],
}),
])
.expect("slot 1 animates"),
);
let entry = UvAnimEntry {
anim: UvLoop::PerSeq {
seqs,
host: Entity::from_raw_u32(7).expect("a valid test entity"),
},
slot: 5,
seed: [0.0, 0.0],
};
// On slot 1, past its step key: the whole V flip shows.
let d = entry.delta(0.0, 0.0, Some((1, 3.0)));
assert!(
(d[1] - 0.605).abs() < 1e-3,
"the flipbook's V offset: {d:?}"
);
// On slot 0 there is no loop at all — the identity, i.e. the built seed.
assert_eq!(entry.delta(0.0, 0.0, Some((0, 3.0))), [0.0; 4]);
// …and an unresolvable host degrades to the same identity, never to a shared-clock sample.
assert_eq!(entry.delta(99.0, 99.0, None), [0.0; 4]);
}
/// The tint twin, same law at the display's 1/255 step.
#[test]
fn the_tint_delta_reproduces_the_old_absolute_write() {
@@ -563,11 +707,11 @@ mod delta_tests {
[s[0], s[1], s[2]]
};
let entry = TintAnimEntry {
anim: anim.clone(),
anim: TintLoop::Shared(anim.clone()),
slot: 4,
seed,
};
let d = entry.delta(0.4);
let d = entry.delta(0.4, 0.4, None);
let old = benilla_assets::quant255(anim.sample(0.4));
for i in 0..3 {
assert_eq!(seed[i] + d[i], old[i], "channel {i}");
+1 -1
View File
@@ -41,7 +41,7 @@ pub(crate) use lazy::{LazyRig, SkinnedTwin};
use mat_anim::tick_anim_materials;
pub use mat_anim::{
playing_seq, register_tint, register_uv, sample_mat_anim, AnimMatPart, MatAnim,
TintAnimMaterials, UvAnimMaterials,
TintAnimMaterials, TintLoop, UvAnimMaterials, UvLoop,
};
/// The client's single global `rand()` stream — the MSVC LCG at `0x7400e5`, returning `[0, 32767]`
+13 -3
View File
@@ -32,8 +32,18 @@ use bevy::render::{Render, RenderApp, RenderSystems};
/// Slots in the table, row 0 included (row 0 is the pinned-zero identity every static material
/// reads). B131 observed material-anim residency grow to ~250 entries on a long single-map
/// session; 512 leaves that headroom ×2 and costs 8 KB.
pub(crate) const MAX_MAT_ANIM_SLOTS: usize = 512;
/// session, which is what sized the original 512.
///
/// **Raised to 2048 by decision 1408**, and measured rather than guessed: the per-placement lane
/// gives a batch whose UV/tint loop differs between sequences one row *per placement*, and a live
/// probe into Upper Blackrock Spire armed **145 rows within half a second of world entry** — 22
/// placements of one 5-batch prop, each batch taking a row for its cutout material and another for
/// its blend twin. The old ceiling would have exhausted in one dungeon, and exhaustion is silent by
/// design (the batch stays at its built seed), i.e. it would have re-frozen exactly the bubbles the
/// decision unfroze. The row count is inherent to the fix, not to how it is addressed — a
/// per-instance row in a shared material would need the same 220. 2048 costs 32 KB per
/// `wow_light`-layout buffer.
pub(crate) const MAX_MAT_ANIM_SLOTS: usize = 2048;
/// Byte offset of the mat-anim region inside a `wow_light`-layout buffer: after the rig-origin
/// table, before the palette rows — mirroring `wow_model.wgsl`'s struct order.
@@ -41,7 +51,7 @@ pub(crate) fn region_offset() -> u64 {
crate::rig_palette::rig_origin_region_offset() + crate::rig_palette::rig_origin_region_bytes()
}
/// Bytes this region adds to every `wow_light`-layout buffer (8 KB at 512 slots).
/// Bytes this region adds to every `wow_light`-layout buffer (32 KB at 2048 slots).
pub(crate) fn region_bytes() -> u64 {
(MAX_MAT_ANIM_SLOTS * 16) as u64
}
+22
View File
@@ -111,6 +111,19 @@ pub struct MatKey {
/// batch — `CavernsOfTimeSky` and Elwynn share no texture today, but nothing enforces that and
/// the collision would be a world doodad silently drawn at the far plane.
sky_depth: bool,
/// **This material belongs to one placement, not to the batch** — `None` for the shared
/// material every instance of a model reuses, which is every batch but one measured class.
///
/// The animated-material registries ([`crate::doodad_anim::UvAnimMaterials`] and its tint twin)
/// are keyed by MATERIAL, so a batch whose animated loop depends on **which sequence its
/// instance is playing** cannot share one and be right: the BRM lava bubbles key their whole
/// flipbook inside a 50 %-weighted variation of animation id 0, and their 15 placements re-roll
/// independently every ~3.3 s (decision 0768), so at any instant some are on slot 0 and some on
/// slot 1. Keying the material by the placement's anim host gives each its own row and its own
/// sequence (decision 1408); the population is 22 batch-channels across 6 models
/// (`benilla-extract uvslotscan`), and the clones expire on the same distance evictor as every
/// other material ([`scope_model_materials`], decision 0785).
instance: Option<Entity>,
}
/// The per-material static terrain-shade **selector** baked into `sun_scale.x` — NOT an intensity
@@ -215,6 +228,11 @@ pub fn model_material(
// and the skybox sort rung instead of the ordinary batch-order eps. See [`MatKey::sky_depth`].
sky_depth: bool,
light: &Buffer,
// The ONE placement this material belongs to, or `None` for the shared batch material every
// instance of the model reuses. `Some` only for a batch whose animated UV/tint loop depends on
// the sequence its instance is playing (decision 1408): the animated-material registries are
// keyed by material, so such a batch cannot share one and be right. See `MatKey::instance`.
instance: Option<Entity>,
) -> Handle<WowModelMaterial> {
let key = MatKey {
light: light.id(),
@@ -239,6 +257,7 @@ pub fn model_material(
window,
zfill: false,
sky_depth,
instance,
};
if let Some(h) = cache.fetch(&key) {
return h;
@@ -583,6 +602,9 @@ pub fn zfill_material(
// A depth-prime twin exists to write depth for a fading model; the sky writes none, so no
// skybox batch ever has one (`M2BatchMaterials::skybox` builds no twins at all).
sky_depth: false,
// A z-fill twin carries no animated channel at all (no texture, no uv/rgb anim), so it can
// never be the per-placement lane — it stays the one shared depth prime per batch.
instance: None,
};
if let Some(h) = cache.fetch(&key) {
return h;
@@ -164,6 +164,7 @@ impl M2BatchMaterials<'_> {
false,
true, // the sky lane
&light,
None, // one shared sky material — no per-sequence channel to key on
))
}
@@ -319,6 +320,7 @@ impl M2BatchMaterials<'_> {
false, // …nor the WINDOW flag
false, // a character composite is never a skybox
&light,
None, // a composite sheet carries no animated UV/tint channel at all
)
};
Some(BatchVariants {
@@ -427,6 +429,11 @@ impl M2BatchMaterials<'_> {
// asks for, so it cannot be one more variant built from `sub` here.
false,
light,
// The ENTITY lane's batches (units, GameObjects, held items): shared per batch, as
// ever. The per-placement lane is the world streamer's alone (decision 1408) — every
// affected model is a placed `World\…` prop, and an entity already resolves its
// sequence through `MatAnim::host`.
None,
)
}
}
@@ -49,6 +49,12 @@ pub(super) fn apply_model_visibility(
// marker + the twin map) instead of a second writer re-swapping the handle it just wrote —
// the exact fight decision 0025's one-authority law exists to prevent.
far_twins: Res<super::FarSideTwins>,
// Every entity that can OWN a billboard card — the model root a card belongs to, or a joint of
// its rig, both of which carry the root's propagated verdict. Read-only, and for the same
// one-authority reason as the two above: a card is a world root, so nothing carries its owner's
// hide to it, and the mirror that used to live in `billboard::face_billboards` was ordered too
// late to survive (decision 1409).
card_owners: Query<&InheritedVisibility>,
mut q: Query<(
&ModelPart,
&GlobalTransform,
@@ -61,6 +67,7 @@ pub(super) fn apply_model_visibility(
Option<&crate::doodad_anim::MatAnim>,
Has<crate::exterior_cull::ExteriorScene>,
Has<super::FarSideOfWater>,
Option<&crate::billboard::BillboardCard>,
)>,
// The building's own MLIQ surfaces (canals, dungeon pools, Ragefire's lava). They carry a
// `WmoGroupVis` like every other piece of their group but no `ModelPart` — they are not model
@@ -94,7 +101,20 @@ pub(super) fn apply_model_visibility(
// serial walk alone blew half the 16.7 ms budget (the Stormwind fps hunt). Every write below
// is change-gated, so the steady state is a pure read fan-out.
q.par_iter_mut().for_each(
|(part, xf, mut vis, fade, tag, mat, aabb, group_vis, mat_anim, exterior, far_side)| {
|(
part,
xf,
mut vis,
fade,
tag,
mat,
aabb,
group_vis,
mat_anim,
exterior,
far_side,
card,
)| {
let toggled_on =
m.kind_visible[kind_index(part.kind)] && m.blend_visible[blend_index(part.blend)];
// `farclip` is the *world-doodad/WMO* draw distance only. Creatures/GameObjects come from the
@@ -170,6 +190,26 @@ pub(super) fn apply_model_visibility(
let own_building = group_vis.is_some_and(|gv| Some(gv.instance) == own_instance);
let exterior_ok = !exterior || own_building || gate.admits(xf, aabb);
// **A billboard card draws only while the model it is a batch OF draws** (decision
// 1409). A card is split out to a world root because its transform belongs to the
// billboard system, so it inherits nothing — and every hide that reaches its model
// through the scene graph (the exterior-scene election on a net root, a transport's
// off-map leg) reaches the card not at all. That is how an Orgrimmar bonfire's glow
// card went on burning over its own culled wood.
//
// The owner's `InheritedVisibility` is last propagate's — one frame of lag, the same
// accepted class as the interior law's. **Only on entity-lane cards**: a world-placement
// card is tagged `ExteriorScene` and `exterior_ok` above already gates it with the rest
// of its placement, while its owner is a joint of its own placement rig, which is never
// hidden apart from the model this system is already hiding whole.
// Fails OPEN on an unreadable owner: an owner that has gone is `face_billboards`'
// despawn case, and deciding a card's draw on absent information is how the world
// flickers.
let owner_hidden = !exterior
&& card
.and_then(crate::billboard::BillboardCard::follows)
.is_some_and(|o| matches!(card_owners.get(o), Ok(v) if !v.get()));
// `Inherited` (not `Visible`) so child submeshes still respect a hidden parent root. A fully
// faded doodad (`fade_alpha == 0`) is culled just like an out-of-range one.
let desired = if toggled_on
@@ -178,6 +218,7 @@ pub(super) fn apply_model_visibility(
&& mat_factor > 0.0
&& portal_visible
&& exterior_ok
&& !owner_hidden
{
Visibility::Inherited
} else {
@@ -332,6 +373,10 @@ type TracedModels<'w, 's> = Query<
&'static InheritedVisibility,
&'static ViewVisibility,
Option<&'static Aabb>,
// Which lane the row is: a world-root billboard CARD inherits nothing, so `inh=true` on it
// means only "no parent", never "my model is drawn" — the one row where the second field
// must not be read as the owner's verdict (decision 1409).
Has<crate::billboard::BillboardCard>,
),
>;
@@ -354,7 +399,7 @@ pub(super) fn trace_model_visibility(
let (cam_pos, cam_fwd) = cam_t.map_or((Vec3::ZERO, Vec3::Z), |t| {
(t.translation(), Vec3::from(t.forward()))
});
for (e, object, xf, vis, inherited, view, aabb) in &q {
for (e, object, xf, vis, inherited, view, aabb, card) in &q {
if !object.label.to_ascii_lowercase().contains(&trace.needle) {
continue;
}
@@ -370,7 +415,7 @@ pub(super) fn trace_model_visibility(
};
let depth = (centre - cam_pos).dot(cam_fwd);
println!(
"VIS_TRACE {e} {label} vis={vis:?} inh={inh} view={view} \
"VIS_TRACE {e} {label} vis={vis:?} inh={inh} view={view} card={card} \
origin=[{ox:.1},{oy:.1},{oz:.1}] \
bound=[{cx:.1},{cy:.1},{cz:.1}] r={radius:.1} depth={depth:.1} \
cam=[{px:.1},{py:.1},{pz:.1}]",
@@ -389,3 +434,84 @@ pub(super) fn trace_model_visibility(
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::billboard::BillboardCard;
use benilla_assets::BillboardInfo;
use benilla_formats::{BillboardKind, ModelBlend};
/// **A billboard card draws only while the model it is a batch of draws** (decision 1409).
///
/// A card is split out to a world root — its transform belongs to the billboard system — so
/// nothing carries its owner's hide to it. `billboard::face_billboards` used to mirror that
/// hide, and its own unit test proved it wrote `Hidden`; the write was thrown away every frame
/// because `BillboardPlace` is ordered only `before(CheckVisibility)`, so it landed after the
/// propagation that would have consumed it and this system overwrote it with `Inherited` in
/// the next `Update`. In Orgrimmar that was a bonfire's glow card burning over its own culled
/// wood. The term lives here now, where nothing runs after it.
#[test]
fn a_card_follows_its_owners_verdict() {
let mut app = App::new();
app.init_resource::<DebugState>()
.init_resource::<ViewDistance>()
.init_resource::<crate::wmo_portal::ExteriorWindows>()
.init_resource::<crate::wmo_portal::CameraInteriorClaim>()
.init_resource::<super::super::FarSideTwins>()
.add_systems(Update, apply_model_visibility);
let owner = app.world_mut().spawn(InheritedVisibility::VISIBLE).id();
let info = BillboardInfo {
bone: 0,
pivot: Vec3::ZERO,
kind: BillboardKind::Spherical,
scale_anim: None,
seq_translations: vec![],
};
let card = app
.world_mut()
.spawn((
ModelPart {
kind: ModelKind::GameObject,
blend: ModelBlend::Blend,
},
GlobalTransform::IDENTITY,
Visibility::Inherited,
BillboardCard::following(&info, owner),
))
.id();
app.update();
assert_eq!(
*app.world().entity(card).get::<Visibility>().unwrap(),
Visibility::Inherited,
"a visible owner leaves its card drawing"
);
app.world_mut()
.entity_mut(owner)
.insert(InheritedVisibility::HIDDEN);
app.update();
assert_eq!(
*app.world().entity(card).get::<Visibility>().unwrap(),
Visibility::Hidden,
"a hidden owner takes its card with it"
);
app.world_mut()
.entity_mut(owner)
.insert(InheritedVisibility::VISIBLE);
app.update();
assert_eq!(
*app.world().entity(card).get::<Visibility>().unwrap(),
Visibility::Inherited,
"a re-shown owner restores its card"
);
// …and the owner going away must NOT blank the card: that is `face_billboards`' despawn
// case, and deciding a draw on absent information is how the world flickers.
app.world_mut().entity_mut(owner).despawn();
app.update();
assert_eq!(
*app.world().entity(card).get::<Visibility>().unwrap(),
Visibility::Inherited,
"an unreadable owner fails OPEN"
);
}
}
@@ -109,6 +109,7 @@ pub(super) fn update_model_particles(
false,
false, // an effect model is never a skybox
&light.0,
None, // a shard's material is shared by the whole emitter
);
// The owner-last draw-order rung, stamped on after the fact: a 3-D model
// particle is one of its owner's emitters exactly like the quad cloud beside
+51 -9
View File
@@ -203,6 +203,35 @@ fn drive_child(
/// building are gated by (0689/1289). One `SystemParam` because they are read together, at one
/// place, and Bevy caps a system at 16 parameters — which `simulate_particles` already sits at, so
/// the portal query joins this bundle rather than becoming a seventeenth.
/// The **owner reads** — one bundle for the two questions this sim asks of an emitter's owner
/// (a joint, a unit root, a placement submesh; never an emitter or child-draw entity, so both
/// members are provably disjoint from the `&mut GlobalTransform` writes below): *where is it* and
/// *is its model being drawn*. A `SystemParam` rather than two parameters because
/// [`simulate_particles`] sits at Bevy's 16-param ceiling, and because the pair is one concept —
/// the owner's frame — that no caller should be able to half-answer.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct Owners<'w, 's> {
transforms:
Query<'w, 's, &'static GlobalTransform, (Without<ParticleEmitter>, Without<ChildDraw>)>,
visibility: Query<'w, 's, &'static InheritedVisibility>,
}
impl Owners<'_, '_> {
/// The owner's live world position — read every frame, never from a stored anchor: a frozen
/// emitter stops refreshing its anchor, so gating on the stored one would latch a moving owner
/// out of existence the first time it crossed the wall.
fn at(&self, owner: Entity) -> Option<Vec3> {
self.transforms.get(owner).ok().map(|gt| gt.translation())
}
/// Is the owner's model NOT being drawn this frame — the entity lane's draw-set term (decision
/// 1409). Last propagate's verdict (one frame of lag on a hide that holds while the model is
/// culled), and an owner we cannot read is not hidden: it falls through to the drain path.
fn hidden(&self, owner: Entity) -> bool {
matches!(self.visibility.get(owner), Ok(v) if !v.get())
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct SceneGates<'w, 's> {
view: Res<'w, crate::view::ViewDistance>,
@@ -385,9 +414,7 @@ pub(super) fn simulate_particles(
interleave: WaterInterleave,
mut commands: Commands,
cam: Query<(Entity, &GlobalTransform, &Frustum, &Camera, &Projection), With<WorldCamera>>,
// Owner reads (joints/units/roots — never emitter or child-draw entities): disjoint from
// both `&mut GlobalTransform` queries below.
transforms: Query<&GlobalTransform, (Without<ParticleEmitter>, Without<ChildDraw>)>,
owners: Owners,
// MODEL-particle instance entities ([`super::model`]): the draw-set gate hides them with
// their frozen emitter.
mut child_draws: Query<
@@ -570,11 +597,26 @@ pub(super) fn simulate_particles(
// frozen emitter stops refreshing its anchor, so gating on the stored one would latch a
// moving owner out of existence the first time it crossed the wall.
let subject = match emitter.owner {
Some(o) => transforms.get(o).ok().map(|gt| gt.translation()),
Some(o) => owners.at(o),
None => Some(emitter.anchor_pos),
};
// **…and the owner's own draw verdict** (decision 1409). An emitter entity is a world
// root — `spawn_emitter` parents it to nothing, because its cloud is anchored, not
// carried — so every hide that reaches its model through the scene graph reaches the
// emitter not at all. The reference has no such gap: it ticks a model's particles
// inside that model's animate step, which runs only for models the frame draws. Ours
// ran regardless, which is a culled GameObject's fire still burning over bare ground —
// the Orgrimmar bonfires, flame and smoke intact with the wood gone. The world lane's
// `EmitterFade` says this in its own vocabulary (terms 4/5); the entity lane has no
// fade sphere to say it with, and its owner's propagated verdict is the same answer.
//
// `InheritedVisibility` is last propagate's — one frame of lag on a verdict that holds
// for as long as the model is culled — and an owner we cannot read falls through to the
// drain path below, exactly like a vanished one.
let owner_hidden = emitter.owner.is_some_and(|o| owners.hidden(o));
if let Some(at) = subject {
if !crate::view::within_farclip(farclip, cam_pos, cam_fwd, at, 0.0) {
if owner_hidden || !crate::view::within_farclip(farclip, cam_pos, cam_fwd, at, 0.0)
{
if !emitter.gated {
emitter.gated = true;
for slot in &emitter.model_instances {
@@ -629,7 +671,7 @@ pub(super) fn simulate_particles(
// no anchor exists — a joint's transform is not the instance matrix, so that leg keeps
// the sign-test fallback (`water_gt = None`).
let water_model = (*anchor).or(*owner);
let water_gt = (*anchor).and_then(|e| transforms.get(e).ok().copied());
let water_gt = (*anchor).and_then(|e| owners.transforms.get(e).ok().copied());
let water_bound = *water_bound;
*age += dt;
// Anchored mode (see [`Particle`]): positions are emitter-relative, so tracking a moving
@@ -643,7 +685,7 @@ pub(super) fn simulate_particles(
// emitter despawns itself. An instant despawn here popped every live particle of a
// fireball's trail at the moment of impact.
if let Some(o) = *owner {
match transforms.get(o) {
match owners.transforms.get(o) {
Ok(gt) => *placement = gt.compute_transform(),
Err(_) => {
*owner = None;
@@ -694,7 +736,7 @@ pub(super) fn simulate_particles(
// The live attach rotation `A(t)` (attached models only — see the field doc). A vanished
// attach entity keeps the last rotation: the pool drains in its final frame.
if let Some(a) = *attach {
if let Ok(gt) = transforms.get(a) {
if let Ok(gt) = owners.transforms.get(a) {
let (_, rot, _) = gt.to_scale_rotation_translation();
*attach_rot = rot;
}
@@ -713,7 +755,7 @@ pub(super) fn simulate_particles(
// one while the pool drains. A whole-model owner keeps anchor == owner — identical math.
match *anchor {
Some(a) => {
if let Ok(gt) = transforms.get(a) {
if let Ok(gt) = owners.transforms.get(a) {
*anchor_pos = gt.translation();
}
}
@@ -196,6 +196,19 @@ pub fn spawn_model_entities(
// distance-fades with its doodad (below — the halo must cull when the lamp does), so it
// keeps its fade twin and its `DoodadFade`.
let steady_interior_prop = interior_probe && sub.billboard.is_none();
// **Does this batch need a material of its own?** (decision 1408.) A batch whose UV or tint
// loop differs between sequences cannot share one: the registries are keyed by material,
// so a shared one has no instance to ask which sequence is playing — and these placements
// re-roll their variation independently every window (0768), so at any instant they are on
// different slots. Keying the material by this placement's anim host gives each its own
// registry entry, its own table row, and its own sequence. Everything else — every batch in
// the world but a measured 28 on this lane, 49 corpus-wide of 24103 (`uvslotscan`) — keeps
// the shared material it always had.
//
// No host ⇒ no sequence to read ⇒ share, and stay at the built seed: a per-sequence batch
// on a model the content gate declined is exactly today's frozen behaviour, not a new one.
let per_seq = sub.uv_seq.is_some() || sub.rgb_seq.is_some();
let seq_owner = per_seq.then_some(rig_root).flatten();
let cutout = model_material(
mat_cache,
materials,
@@ -220,6 +233,7 @@ pub fn spawn_model_entities(
sub.window,
false, // the world streamer never spawns a skybox
light,
seq_owner,
);
// The blend twin for the distance-fade feather pass (reuse the cutout when already blend, or when
// this is a non-fading interior prop). A MULTIPLY batch (Mod/Mod2x — the weapon-rack
@@ -262,6 +276,7 @@ pub fn spawn_model_entities(
sub.window,
false, // the world streamer never spawns a skybox
light,
seq_owner,
)
};
// Register both variants' materials for the per-frame UV scroll (idempotent per material —
@@ -269,15 +284,57 @@ pub fn spawn_model_entities(
// A period-0 loop is a constant the material seed already wrote (`sample(0.0)` IS its
// forever value) — registering it would buy a per-frame re-write of the same number
// (1375), so only a real loop enters the registry.
if let Some(anim) = sub.uv_anim.as_ref().filter(|a| a.period > 0.0) {
crate::doodad_anim::register_uv(uv_reg, anim_table, materials, cutout.id(), anim);
crate::doodad_anim::register_uv(uv_reg, anim_table, materials, blend.id(), anim);
// The per-placement lane first: its set replaces the shared loop outright, and it only
// arms when this placement actually has a host to read a sequence from.
if let (Some(seqs), Some(host)) = (sub.uv_seq.as_ref(), seq_owner) {
for id in [cutout.id(), blend.id()] {
crate::doodad_anim::register_uv(
uv_reg,
anim_table,
materials,
id,
crate::doodad_anim::UvLoop::PerSeq {
seqs: seqs.clone(),
host,
},
);
}
} else if let Some(anim) = sub.uv_anim.as_ref().filter(|a| a.period > 0.0) {
for id in [cutout.id(), blend.id()] {
crate::doodad_anim::register_uv(
uv_reg,
anim_table,
materials,
id,
crate::doodad_anim::UvLoop::Shared(anim.clone()),
);
}
}
// …and for the per-frame tint re-sample (the animated M2Color RGB, same shared clock —
// the same invisible seq-band phase divergence as the UV scroll, recorded there).
if let Some(anim) = sub.rgb_anim.as_ref().filter(|a| a.period > 0.0) {
crate::doodad_anim::register_tint(tint_reg, anim_table, materials, cutout.id(), anim);
crate::doodad_anim::register_tint(tint_reg, anim_table, materials, blend.id(), anim);
if let (Some(seqs), Some(host)) = (sub.rgb_seq.as_ref(), seq_owner) {
for id in [cutout.id(), blend.id()] {
crate::doodad_anim::register_tint(
tint_reg,
anim_table,
materials,
id,
crate::doodad_anim::TintLoop::PerSeq {
seqs: seqs.clone(),
host,
},
);
}
} else if let Some(anim) = sub.rgb_anim.as_ref().filter(|a| a.period > 0.0) {
for id in [cutout.id(), blend.id()] {
crate::doodad_anim::register_tint(
tint_reg,
anim_table,
materials,
id,
crate::doodad_anim::TintLoop::Shared(anim.clone()),
);
}
}
// `MeshTag` is the per-instance lighting/fade scalar: an interior prop carries its SH-probe
// slot index here (the shader evaluates the folded probe instead of the sky base);
@@ -406,6 +463,7 @@ pub fn spawn_model_entities(
// unmarked one.
if sub.uv_anim.as_ref().is_some_and(|a| a.period > 0.0)
|| sub.rgb_anim.as_ref().is_some_and(|a| a.period > 0.0)
|| seq_owner.is_some()
{
commands
.entity(entity)
@@ -602,8 +602,15 @@ pub(super) fn spawn_loaded_placements(
// visible-group walk `0x698720`), so furniture never outlives its room (decision 0689)
// and a prop several rooms name is drawn from ANY of them. Every submesh spawned above
// is this one prop, so they all share the one key.
//
// The anim-host ROOT is skipped, for the reason the ADT site skips it (0784): it is a
// joint hierarchy, not geometry — no `ModelPart`, no `Aabb` — so it lands in the cull's
// fail-open arm and inflates every "objects tested" count without ever being drawn. The
// ADT lane has excluded it since 0784; this lane did not, which made the same root a
// silent passenger of the WMO prop path's counts.
let anim_root = host.as_ref().map(|h| h.root);
if let (Some(instance), false) = (portal_instance, d.groups.is_empty()) {
for &entity in &ents {
for &entity in ents.iter().filter(|e| Some(**e) != anim_root) {
commands.entity(entity).insert((
WmoGroupVis {
instance,
@@ -292,13 +292,15 @@ pub(super) fn track_unit_interiors(
streamer: Res<TerrainStreamer>,
adt_tiles: Res<Assets<AdtTile>>,
residency: Res<crate::interior::WmoResidency>,
mut units: Query<
(Entity, &GlobalTransform, Option<&mut UnitWmoRoom>),
With<crate::world_unit::WorldUnit>,
>,
mut units: Query<(
Entity,
&GlobalTransform,
&crate::world_unit::WorldUnit,
Option<&mut UnitWmoRoom>,
)>,
) {
let generation = residency.generation();
for (entity, transform, claim) in &mut units {
for (entity, transform, body, claim) in &mut units {
// World position, not the local one: a mounted unit's `Transform` is seat-relative
// (0441), and its room is decided where it actually stands.
let pos = transform.translation();
@@ -313,7 +315,18 @@ pub(super) fn track_unit_interiors(
continue;
}
}
let room = room_at(&wmos, &instances, &streamer, &adt_tiles, pos);
// The under-floor fallback's reach for THIS body: the height of its own bound's centre
// above its origin, floored at [`ROOM_UNDER_FLOOR_TOLERANCE`]. A placed object's origin is
// wherever the artist put it *inside its own model*, so the middle of that model is a point
// the body genuinely occupies — self-scaling, and it cannot reach a floor the body does not
// already straddle. The flat tolerance alone left Orgrimmar's Onyxia trophy post (origin
// 3.25 yd under group 133's floor) still reading outdoors.
let reach = body
.bound
.map(|b| transform.transform_point(Vec3::from(b.center)).y - pos.y)
.unwrap_or(0.0)
.max(ROOM_UNDER_FLOOR_TOLERANCE);
let room = room_at(&wmos, &instances, &streamer, &adt_tiles, pos, reach);
let next = UnitWmoRoom {
room,
at: pos,
@@ -334,8 +347,28 @@ pub(super) fn track_unit_interiors(
}
}
/// The FLOOR on how far above its own origin a body's room claim looks for the surface it stands
/// on, when the position cast found nothing at all below it ([`room_at`]'s second pass). The reach
/// itself is per body — the height of its own bound's centre, see [`track_unit_interiors`] — and
/// this is what a body with no bound yet, or a squat one, gets.
///
/// **A placed object's origin is wherever the artist put it, and for anything planted in the ground
/// that is UNDER the surface** — a firepit's origin sits in the pit, a signpost's at the buried foot
/// of its post. A ray down from such an origin passes beneath the floor the object stands on and
/// finds nothing, so the body reads "outdoors" in the middle of a building. Orgrimmar: 32 of 133
/// streamed bodies at once, measured 0.45–1.16 yd under their floor face (decision 1409).
///
/// Two yards covers that spread with margin and cannot reach the storey above — no room we draw is
/// under two yards tall. A **fallback, not a lift**: it runs only where the position cast found no
/// face at all, so it can turn "outdoors" into a room and can never move a body between two rooms it
/// was already choosing from. The primary pass keeps [`POSITION_PROBE_LIFT`] and the buried-terrain
/// race that constant was chosen for.
pub(crate) const ROOM_UNDER_FLOOR_TOLERANCE: f32 = 2.0;
/// The WMO room a world position stands in — the placement + group of the nearest **face** under it,
/// faces-only ([`area_down_ray`], EXTERIOR `0x8` excluded, terrain raced), first claim wins.
/// faces-only ([`area_down_ray`], EXTERIOR `0x8` excluded, terrain raced), first claim wins; on a
/// total miss, a second pass from `reach` higher catches a body whose origin is authored below the
/// surface it stands on ([`ROOM_UNDER_FLOOR_TOLERANCE`]).
///
/// The shared body of the per-unit claim; the same shape as [`track_area_interior`]'s loop, returning
/// the placement identity instead of the `WMOAreaTable` keys.
@@ -345,8 +378,22 @@ fn room_at(
streamer: &TerrainStreamer,
adt_tiles: &Assets<AdtTile>,
feet_world: Vec3,
reach: f32,
) -> Option<WmoRoom> {
let probe_world = feet_world + Vec3::Y * POSITION_PROBE_LIFT;
room_cast(wmos, instances, streamer, adt_tiles, feet_world, 0.0)
.or_else(|| room_cast(wmos, instances, streamer, adt_tiles, feet_world, reach))
}
/// One faces-only room cast from `feet_world + rise` — the shared body of [`room_at`]'s two passes.
fn room_cast(
wmos: &Assets<WmoModel>,
instances: &Query<(Entity, &WmoPortalInstance)>,
streamer: &TerrainStreamer,
adt_tiles: &Assets<AdtTile>,
feet_world: Vec3,
rise: f32,
) -> Option<WmoRoom> {
let probe_world = feet_world + Vec3::Y * (POSITION_PROBE_LIFT + rise);
let terrain = terrain_height_under(streamer, adt_tiles, probe_world);
for (entity, inst) in instances {
let Some(model) = wmos.get(&inst.handle) else {
@@ -717,6 +717,43 @@ mod tests {
area_down_ray(tris, &bounds, &grids, nav, eye, terrain_z, EXTERIOR)
}
/// **A body planted UNDER the floor it stands on is invisible to the position cast, and the
/// under-floor fallback is what finds it** (decision 1409). Orgrimmar's bonfires, braziers,
/// meat racks and shop signs are authored 0.45–1.16 yd below the group floor they sit in — a
/// ray down from the origin passes beneath that floor and reports open ground, which the
/// exterior-scene election reads as "outdoors" and hides. `room_at`'s second pass re-casts from
/// `ROOM_UNDER_FLOOR_TOLERANCE` higher; this pins both halves of why that works: the cast from
/// the origin really does miss, the lifted one really does find the same floor, and the lift is
/// far enough to cover the measured spread and no further.
#[test]
fn an_under_floor_origin_is_found_only_by_the_lifted_cast() {
let floor = [quad(0.0, 20.0)];
let nav = [nav(0, [-20.0, -20.0, 0.0], [20.0, 20.0, 0.0], 0, 0)];
let lift = super::super::interior::POSITION_PROBE_LIFT;
let tol = super::super::interior::ROOM_UNDER_FLOOR_TOLERANCE;
// The worst measured burial (Orgrimmar bonfire 177026, 1.16 yd under group 134's floor).
for buried in [0.45_f32, 1.16] {
let feet = -buried;
assert_eq!(
area_ray(&floor, &nav, [0.0, 0.0, feet + lift], None),
None,
"the position cast must miss a floor above the origin ({buried} yd)"
);
assert_eq!(
area_ray(&floor, &nav, [0.0, 0.0, feet + lift + tol], None),
Some(0),
"the under-floor fallback must find it ({buried} yd)"
);
}
// …and the lift is a tolerance, not a licence: a floor further above than it stays unclaimed,
// which is what keeps the storey above out of reach.
assert_eq!(
area_ray(&floor, &nav, [0.0, 0.0, -(tol + 1.0) + lift + tol], None),
None,
"a floor beyond the tolerance must stay unclaimed"
);
}
/// **The column index never changes a down-ray verdict.** A dungeon-shaped face set (a big
/// stack of small floor quads at varying heights, spread over several groups) answered with
/// the per-group column index must equal the answer from the plain linear scan, column for