air elementals, Ragnaros and the forge lava animate

- every unit, GameObject and held item runs its texture animation: air elementals, Ragnaros, the Ash'ari Crystal, Ironforge's forge lava, the Scourge rune circles and the Zul'Gurub forcefields were static (2295)
- a GameObject whose animation slots disagree gets a material of its own: the Blood of Heroes pools bubble and the Freezing Trap's glow pulses (2295)
- three sound kits with a doubled separator in their path were silent, the flight-path-discovered chime among them; the Ashbringer's lines stay silent, as they are in 1.12 (2294)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-17 16:42:33 +07:00
co-authored by Claude Fable 5.1
parent d3a4a0df76
commit 5f622e6fe8
25 changed files with 2765 additions and 320 deletions
+20
View File
@@ -1203,6 +1203,10 @@ fn update_display_models(
mut forms: ResMut<benilla_world::model_forms::ModelForms>,
asset_server: Res<AssetServer>,
mut mats: benilla_world::model_render::M2BatchMaterials,
// The UV lane a batch's texture transform is delivered on (decision 2295): an entity batch is
// seeded AND registered in one call, so a display built here can never be marked-but-frozen.
mut uv_reg: ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
mut anim_table: ResMut<benilla_world::mat_anim_table::MatAnimTable>,
// The bone-pile display cache + the ChrRaces fileStrings its paths are built from (1706).
mut bones: ResMut<corpse::BonesModels>,
// The glue-preview want (decisions 0423 + 0465): the glue screens' look's body displayId, so
@@ -1218,6 +1222,15 @@ fn update_display_models(
return; // no lighting yet → no materials to build
}
let (m2s, wmos) = (&model_assets.0, &model_assets.1);
// `instance: None` — every display cache here is keyed by display id and shared by every unit
// wearing it, so these materials belong to the batch. The one population that needs its own
// (a GameObject whose file-sequence slots bake different loops) takes its clone at spawn,
// where the host entity exists (`attach::dress`).
let mut uv = benilla_world::model_render::EntityUvLane {
reg: &mut uv_reg,
table: &mut anim_table,
instance: None,
};
// The (kind, display) pairs live in the world this frame — cheap to collect.
let mut actives: Vec<(EntityKind, u32)> = entities
@@ -1310,6 +1323,7 @@ fn update_display_models(
&mut forms,
&asset_server,
&mut mats,
&mut uv,
false, // gameobject: creatures — no hull collider, no bake variant
);
}
@@ -1340,6 +1354,7 @@ fn update_display_models(
&mut forms,
&asset_server,
&mut mats,
&mut uv,
true, // gameobject: hull collider + the interior BAKE material variant
);
}
@@ -1365,6 +1380,7 @@ fn update_display_models(
&mut forms,
&asset_server,
&mut mats,
&mut uv,
false, // gameobject: a prop body — unit lighting, no hull collider
);
}
@@ -1391,6 +1407,7 @@ fn update_display_models(
&mut forms,
&asset_server,
&mut mats,
&mut uv,
false, // gameobject: held items — unit lighting, no collider
);
}
@@ -1411,6 +1428,7 @@ fn update_display_models(
&mut forms,
&asset_server,
&mut mats,
&mut uv,
false, // gameobject: effects — unit lighting, no collider
);
}
@@ -1431,6 +1449,7 @@ fn update_display_models(
&mut forms,
&asset_server,
&mut mats,
&mut uv,
false, // gameobject: effects — unit lighting, no collider
);
}
@@ -1631,6 +1650,7 @@ mod display_mirror_tests {
}),
alpha_anim: None,
rgb_anim: None,
rgb_seq: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
+221 -15
View File
@@ -103,6 +103,161 @@ pub(super) fn part_materials<'a>(
}
}
/// The material store and the two animated-material registries, for the one entity population
/// that needs a material of its **own** rather than the batch's (decision 2295).
///
/// Taken as one bundle, and taken by the dressing path itself rather than by a system that fixes
/// parts up afterwards, because the cloned handles have to be in hand *before* the part's
/// [`InteriorLit`](benilla_world::interior) and [`FadeMaterials`] are built from them — a later
/// pass would have to reach into both and rewrite what the classifier already wrote.
pub(super) struct OwnMats<'a> {
pub(super) store: &'a mut Assets<WowModelMaterial>,
pub(super) uv: &'a mut benilla_world::doodad_anim::UvAnimMaterials,
pub(super) tint: &'a mut benilla_world::doodad_anim::TintAnimMaterials,
pub(super) table: &'a mut benilla_world::mat_anim_table::MatAnimTable,
}
/// One part's **own** variant set — [`PartMaterials`]' owning twin, held by the spawn for as long
/// as it takes to dress the part.
struct OwnedMaterials {
steady: Handle<WowModelMaterial>,
interior: Option<Handle<WowModelMaterial>>,
fade_blend: Option<Handle<WowModelMaterial>>,
bake: Option<Handle<WowModelMaterial>>,
bake_blend: Option<Handle<WowModelMaterial>>,
zfill: Option<Handle<WowModelMaterial>>,
}
impl OwnedMaterials {
fn borrow(&self) -> PartMaterials<'_> {
PartMaterials {
steady: &self.steady,
interior: self.interior.as_ref(),
fade_blend: self.fade_blend.as_ref(),
bake: self.bake.as_ref(),
bake_blend: self.bake_blend.as_ref(),
zfill: self.zfill.as_ref(),
}
}
}
/// Give this part a material set of its **own**, registered against its own instance's anim host —
/// or `None`, which is every part but a measured thirteen batches (decision 2295).
///
/// **Who needs one.** A batch whose file-sequence slots bake *different* UV or tint loops cannot be
/// served by a material every instance of the model shares: the registries are keyed by material,
/// so one row would have to answer for placements that are playing different slots. That is
/// 1408's finding, and the entity side of it is six GameObject models — `BloodOfHeroes`'s five
/// bubble sheets (slot 0 holds them still, slot 1 sweeps them, at freq 16384/16383, so the pools
/// bubble independently), `Valentines_Blanket`, `G_GnomeTerminal`'s screen, and on the tint
/// channel `G_FreezingTrap`'s glow card, `OrgrimmarPentagram` and `ScholomanceCrystalBall01`.
/// **Four of the five tint batches bake nothing in slot 0**, so their shared material can only
/// ever seed white however faithfully it is ticked — the hunter's trap glows flat instead of
/// pulsing blue-white as it arms.
///
/// **Why it is gated on `GameObject`.** Not as a safety valve: the census is the reason. No unit,
/// no player body, no corpse and no held item in the shipped corpus authors a per-sequence set on
/// either channel (`benilla-extract entityuvscan`), so widening this past GameObjects would add a
/// clone-and-register branch to the hottest spawn path in the client for an empty population. If
/// a future model joins it, the gate is the line to move and this paragraph is why.
fn own_per_sequence_materials(
part: &EntityPart,
dress: &PartDress<'_>,
own: &mut OwnMats<'_>,
) -> Option<OwnedMaterials> {
let loops = part.uv_loops();
let (uv_seq, tint_seq) = (loops.seqs.is_some(), part.rgb_seq.is_some());
if dress.kind != ModelKind::GameObject || !(uv_seq || tint_seq) {
return None;
}
let src = part_materials(part, dress.char_mats);
// The shared variants may still be PARKED (`model_render::lazy` reserves a handle and holds
// the value until something view-visible binds it), so each is realized before it is read —
// the same order `spell_fx`'s clone takes, and the reason 2038's registrations all returned
// `None` when they skipped it.
let mut clone = |h: &Handle<WowModelMaterial>| -> Option<Handle<WowModelMaterial>> {
benilla_world::model_render::lazy::realize(own.store, h.id());
let mut mat = own.store.get(h.id()).cloned()?;
// A clone leaves the shared table (decision 1381): its rows are its own, and a carried
// slot would add the shared delta on top of them.
mat.extension.anim_slots = Vec4::ZERO;
Some(own.store.add(mat))
};
let owned = OwnedMaterials {
steady: clone(src.steady)?,
interior: src.interior.and_then(&mut clone),
fade_blend: src.fade_blend.and_then(&mut clone),
bake: src.bake.and_then(&mut clone),
bake_blend: src.bake_blend.and_then(&mut clone),
// The depth-prime twin is NOT cloned and NOT registered, exactly as on the placed-doodad
// lane: it draws only while the part feathers, and its alpha test reading unscrolled UVs
// for those frames is the behaviour the streamer has always had.
zfill: src.zfill.cloned(),
};
// Every variant the part can swap to over its life — indoors, feathering, both — takes a row,
// or a GameObject that walks under a roof would stop mid-scroll.
for id in [
Some(&owned.steady),
owned.interior.as_ref(),
owned.fade_blend.as_ref(),
owned.bake.as_ref(),
owned.bake_blend.as_ref(),
]
.into_iter()
.flatten()
.map(Handle::id)
{
if uv_seq {
benilla_world::doodad_anim::register_entity_uv(
own.uv,
own.table,
own.store,
id,
&loops,
Some(dress.unit),
);
}
if let Some(seqs) = &part.rgb_seq {
benilla_world::doodad_anim::register_tint(
own.tint,
own.table,
own.store,
id,
benilla_world::doodad_anim::TintLoop::PerSeq {
seqs: seqs.clone(),
host: dress.unit,
},
);
}
}
Some(owned)
}
/// Put this part in `tick_anim_materials`' draw scan — **iff something registered a row for it**.
///
/// The marker and the registration have to ask ONE question, because 2038's whole bug class is the
/// two disagreeing: a marked part with no registry entry trips the lane's own tripwire, and a
/// registered material that no marked part accounts for freezes silently, which is how every
/// waterfall in the game stood still for three days. So this is not a predicate over the part's
/// channels — it is a statement about what actually happened a few lines up.
///
/// Two registrations can have happened. The **shared** UV lane's, made inside
/// `entity_variants` when the batch's transform moves at all (`UvLoops::animates`, which is not
/// `uv_anim.is_some()`: a rotate-only batch answers `None` there). And this instance's **own**
/// clone, when it has one — which is the only way the TINT channel is ever registered on an
/// entity, and why `owned` has to be consulted rather than the part's `rgb_seq` re-tested: the
/// clone can decline (a full table, a dead handle), and a marker for a registration that did not
/// happen is the first half of the bug above.
fn mark_mat_anim(
child: &mut bevy::ecs::system::EntityCommands<'_>,
part: &EntityPart,
owned: &Option<OwnedMaterials>,
) {
if part.uv_loops().animates() || owned.is_some() {
child.insert(benilla_world::doodad_anim::AnimMatPart);
}
}
/// Stamp a pickable part (or card) with the pick population its kind belongs to: the mouseover
/// pickers filter by these markers instead of kind-comparing every `WorldObject` row per frame.
/// One site for both the mesh-part and the billboard-card spawn, so the two can't drift.
@@ -183,10 +338,11 @@ pub(super) fn spawn_group(
part: &EntityPart,
group: &super::merge::BodyGroup,
dress: &PartDress,
own: &mut OwnMats<'_>,
) -> bool {
let members =
(group.members.len() > 1).then(|| super::merge::DressedGroup(group.members.clone().into()));
spawn_part(commands, part, group.first() as usize, members, dress)
spawn_part(commands, part, group.first() as usize, members, dress, own)
}
/// Spawn one mesh part (or billboard card) of a body under `dress.unit`; `group` is the merged
@@ -197,11 +353,18 @@ pub(super) fn spawn_part(
index: usize,
group: Option<super::merge::DressedGroup>,
dress: &PartDress,
own: &mut OwnMats<'_>,
) -> bool {
if let Some(info) = &part.billboard {
return spawn_billboard_part(commands, part, index, info, dress);
return spawn_billboard_part(commands, part, index, info, dress, own);
}
let mats = part_materials(part, dress.char_mats);
// A material of this instance's OWN, for the measured thirteen batches whose slots disagree
// (2295) — resolved first, because everything below dresses from whatever this returns.
let owned = own_per_sequence_materials(part, dress, own);
let mats = match &owned {
Some(o) => o.borrow(),
None => part_materials(part, dress.char_mats),
};
let set = mats.fade_set();
// A freshly-streamed CGObject appear-fades in (decision 0032): spawn already on the blend twin
// with a ≈0 `MeshTag`, so it doesn't flash opaque for a frame before `apply_render_fade` ramps
@@ -314,6 +477,7 @@ pub(super) fn spawn_part(
dress.unit,
));
}
mark_mat_anim(&mut child, part, &owned);
effective.dress(&mut child, &set)
}
@@ -328,6 +492,7 @@ fn spawn_billboard_part(
index: usize,
info: &benilla_assets::BillboardInfo,
dress: &PartDress,
own: &mut OwnMats<'_>,
) -> bool {
let anchor = commands
.spawn((
@@ -363,12 +528,15 @@ fn spawn_billboard_part(
// (decision 0836). Splitting the batch into a world-root entity is benilla's parenting detail;
// it is not a reason for the glow on a weapon's gems to blaze at full strength over a body that
// has not appeared yet.
let set = FadeSet {
steady: &part.material,
blend: part.fade_blend.as_ref(),
bake_blend: part.material_interior_bake_blend.as_ref(),
zfill: part.zfill.as_ref(),
// A card may need a material of its own for exactly the same reason a mesh part does — and
// on this population it is the card that needs it: `World\Goober\G_FreezingTrap`'s animated
// batch IS the additive glow card, whose blue-white pulse is keyed in file slot 2 alone.
let owned = own_per_sequence_materials(part, dress, own);
let mats = match &owned {
Some(o) => o.borrow(),
None => part_materials(part, dress.char_mats),
};
let set = mats.fade_set();
let effective = PartFade::resolve(dress.fade, &set);
let (init_mat, tag_alpha) = effective.seed(&set, dress.now);
let mut card = commands.spawn((
@@ -401,9 +569,9 @@ fn spawn_billboard_part(
card.insert(aabb);
}
if let Some(lit) = part_interior_lit(
&part.material,
part.material_interior.as_ref(),
part.material_interior_bake.as_ref(),
mats.steady,
mats.interior,
mats.bake,
dress.bake_center,
dress.unit,
) {
@@ -417,6 +585,11 @@ fn spawn_billboard_part(
dress.unit,
));
}
// …and its batch's material animation, on the same rule as a mesh part (2295). A card is a
// batch of the unit's own model; the split into a world-root entity is benilla's parenting
// detail, never a reason for one batch of a model to animate and its sibling not to — and on
// this population it is the card that carries it.
mark_mat_anim(&mut card, part, &owned);
let armed = effective.dress(&mut card, &set);
let card = card.id();
// The card follows the JOINT when the unit is rigged, so it does not cascade with the anchor's
@@ -431,6 +604,28 @@ fn spawn_billboard_part(
#[cfg(test)]
mod tests {
use super::*;
/// A standalone [`OwnMats`] for the spawn tests — none of them dresses a GameObject, so the
/// lane is never taken; it exists so the spawn's signature can stay non-optional, which is
/// what stops a production caller passing `None` and silently losing the clone.
#[derive(Default)]
struct TestOwn {
store: Assets<WowModelMaterial>,
uv: benilla_world::doodad_anim::UvAnimMaterials,
tint: benilla_world::doodad_anim::TintAnimMaterials,
table: benilla_world::mat_anim_table::MatAnimTable,
}
impl TestOwn {
fn lane(&mut self) -> OwnMats<'_> {
OwnMats {
store: &mut self.store,
uv: &mut self.uv,
tint: &mut self.tint,
table: &mut self.table,
}
}
}
use benilla_world::model_fade::{FadeMaterials, PendingAppearFade};
/// One synthetic batch at `char_slot`/`two_sided`, carrying its own (distinguishable) built
@@ -456,6 +651,7 @@ mod tests {
billboard: None,
alpha_anim: None,
rgb_anim: None,
rgb_seq: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
@@ -550,11 +746,12 @@ mod tests {
now: 0.0,
fade: JoinedFade::Steady,
};
let mut own = TestOwn::default();
let mut queue = bevy::ecs::world::CommandQueue::default();
let armed = {
let world = app.world();
let mut commands = Commands::new(&mut queue, world);
spawn_part(&mut commands, &fadeable, 0, None, &dress)
spawn_part(&mut commands, &fadeable, 0, None, &dress, &mut own.lane())
};
queue.apply(app.world_mut());
assert!(!armed, "steady: no ramp armed");
@@ -614,11 +811,12 @@ mod tests {
now: 0.0,
fade: JoinedFade::Pending { since: SINCE },
};
let mut own = TestOwn::default();
let mut queue = bevy::ecs::world::CommandQueue::default();
let armed = {
let world = app.world();
let mut commands = Commands::new(&mut queue, world);
spawn_part(&mut commands, &card, 0, None, &dress)
spawn_part(&mut commands, &card, 0, None, &dress, &mut own.lane())
};
queue.apply(app.world_mut());
assert!(
@@ -690,12 +888,20 @@ mod tests {
now: 0.0,
fade: JoinedFade::Steady,
};
let mut own = TestOwn::default();
let mut queue = bevy::ecs::world::CommandQueue::default();
{
let world = app.world();
let mut commands = Commands::new(&mut queue, world);
spawn_part(&mut commands, &part(None, false), 0, None, &dress);
spawn_part(&mut commands, &billboard, 1, None, &dress);
spawn_part(
&mut commands,
&part(None, false),
0,
None,
&dress,
&mut own.lane(),
);
spawn_part(&mut commands, &billboard, 1, None, &dress, &mut own.lane());
}
queue.apply(app.world_mut());
let world = app.world_mut();
@@ -364,6 +364,7 @@ mod tests {
welded_billboard: false,
alpha_anim: None,
rgb_anim: None,
rgb_seq: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
+23 -1
View File
@@ -348,6 +348,14 @@ pub(super) fn attach_entity_visuals(
// The merged-group forms (`merge`): built on a body's first silhouette, shared after.
ResMut<Assets<Mesh>>,
ResMut<merge::MergedFormsCache>,
// …and the animated-material lane a spawned part may need a material of its OWN on
// (decision 2295) — a GameObject whose file-sequence slots bake different UV or tint
// loops. Taken by the dressing path itself, because the clone has to exist before the
// part's interior and fade records are built from it; nested here for this tuple's own
// stated reason, the 16-param limit.
ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
ResMut<benilla_world::doodad_anim::TintAnimMaterials>,
ResMut<benilla_world::mat_anim_table::MatAnimTable>,
),
// The owned skin-palette table (decision 0720): every skinned instance claims a rig slot.
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
@@ -365,6 +373,9 @@ pub(super) fn attach_entity_visuals(
mut mats,
mut meshes,
mut merged,
mut uv_reg,
mut tint_reg,
mut anim_table,
) = skin_build;
// Arm each entity's appear-fade at the moment its visual attaches (≈ its first-visible moment).
let now = time.elapsed_secs();
@@ -870,7 +881,18 @@ pub(super) fn attach_entity_visuals(
for group in &groups {
let forms = merged.forms(parts, group, &mut meshes);
let part = merge::group_part(parts, group, forms);
unit_will_fade |= spawn_group(&mut commands, &part, group, &dress);
unit_will_fade |= spawn_group(
&mut commands,
&part,
group,
&dress,
&mut dress::OwnMats {
store: mats.materials(),
uv: &mut uv_reg,
tint: &mut tint_reg,
table: &mut anim_table,
},
);
}
// Mirror the appear-fade clock onto the unit root (see `unit_will_fade` above): a held item
// / helm / shoulder attaching later reads this to join the same ramp
@@ -115,6 +115,15 @@ pub(in crate::entities) fn redress_player_looks(
ResMut<Assets<Mesh>>,
ResMut<MergedFormsCache>,
),
// The animated-material lane a re-dressed part may need a material of its own on (2295).
// Plumbing here rather than a live case: a re-dress is a PLAYER's gear change, and no
// character batch in the corpus carries a per-sequence loop — but the dressing law is one law,
// and a path that could not express it would be a place for the two to drift.
mut own_lane: (
ResMut<benilla_world::doodad_anim::UvAnimMaterials>,
ResMut<benilla_world::doodad_anim::TintAnimMaterials>,
ResMut<benilla_world::mat_anim_table::MatAnimTable>,
),
time: Res<Time>,
) {
let (
@@ -288,7 +297,18 @@ pub(in crate::entities) fn redress_player_looks(
}
let forms = merged.forms(parts, group, &mut meshes);
let part = merge::group_part(parts, group, forms);
spawn_group(&mut commands, &part, group, &dress);
spawn_group(
&mut commands,
&part,
group,
&dress,
&mut super::dress::OwnMats {
store: mats.materials(),
uv: &mut own_lane.0,
tint: &mut own_lane.1,
table: &mut own_lane.2,
},
);
shown += 1;
}
// One line per re-dress — a handful per session, and the only readout of a mechanism whose
@@ -398,6 +418,7 @@ mod tests {
billboard: None,
alpha_anim: None,
rgb_anim: None,
rgb_seq: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
@@ -429,7 +450,12 @@ mod tests {
.init_resource::<MergedFormsCache>()
// The engine's material cache — normally `model_render::plugin`'s, which this bare
// harness does not install.
.init_resource::<benilla_world::model_render::ModelMaterials>();
.init_resource::<benilla_world::model_render::ModelMaterials>()
// The animated-material lane the dressing path now takes (2295) — never exercised by
// a re-dress, which is a player's gear change, but the system asks for it.
.init_resource::<benilla_world::doodad_anim::UvAnimMaterials>()
.init_resource::<benilla_world::doodad_anim::TintAnimMaterials>()
.init_resource::<benilla_world::mat_anim_table::MatAnimTable>();
let mut dm = empty_display();
dm.parts = Some(geosets.iter().map(|g| part(*g)).collect());
+33 -3
View File
@@ -127,11 +127,18 @@ pub(super) struct EntityPart {
/// the **first key** seeded as the material tint (pixel-identical to the old static bake);
/// the effect lane clones + ticks the tint per instance. `None` for constant tints.
pub(super) rgb_anim: Option<std::sync::Arc<benilla_formats::RgbAnim>>,
/// The per-file-sequence-slot form of [`Self::rgb_anim`], carried only where the slots
/// disagree (decision 1408) — [`Self::uv_seq`]'s twin, and the reason a GameObject may need a
/// material of its own. Five batches on three GameObject models author one, and **four of
/// them bake nothing in slot 0**, so `rgb_anim` is `None` for them and a shared material can
/// only ever seed white: the hunter's Freezing Trap glow card is the class (decision 2295).
pub(super) rgb_seq: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 3]>>>,
/// The part's **texture-transform (UV) loop** — the translation track that scrolls this
/// batch's stage UVs (decision 0130 phase 3, wow-re `m2-texanim-uv`). The **effect lane**
/// ([`super::spell_fx`]) samples it per instance on its own clip clock, through a material
/// clone (decision 2282); the unit/GameObject lane does not consume it yet. `None` for the
/// ~98.6% of batches with no texture transform, and for all WMO parts.
/// clone (decision 2282); the unit/GameObject lane runs it on the shared, deduped material
/// (decision 2295). `None` for the ~98.6% of batches with no texture transform, and for all
/// WMO parts.
pub(super) uv_anim: Option<std::sync::Arc<benilla_formats::UvAnim>>,
/// The per-file-sequence-slot form of [`Self::uv_anim`], carried only where the slots
/// disagree (decision 1408) — an effect that advances `Stand` → `Hold` → `Decay` reads the
@@ -151,6 +158,23 @@ pub(super) struct EntityPart {
pub(super) ground_quad: Option<benilla_formats::GroundQuad>,
}
impl EntityPart {
/// This part's four baked texture-transform channels, in the shape the engine's lanes take
/// them — **the one place a spawner reads them**, so "does this batch animate its transform"
/// has a single answer wherever it is asked (the effect attach's clone test, the entity
/// spawn's [`benilla_world::doodad_anim::AnimMatPart`] marker). `UvLoops::animates` is that
/// question: a scale-only transform and a dead-slot-0 translation each answer `None` to the
/// channel you would check first.
pub(super) fn uv_loops(&self) -> benilla_world::doodad_anim::UvLoops {
benilla_world::doodad_anim::UvLoops {
seqs: self.uv_seq.clone(),
single: self.uv_anim.clone(),
rot: self.uv_rot_seq.clone(),
scale: self.uv_scale_seq.clone(),
}
}
}
/// Everything needed to render one display id, cached so a model is loaded + built once and shared by
/// every entity of that display. `parts` is `None` while the asset loads, then `Some` — empty if the
/// model failed / has no geometry (→ cube/none fallback), else the spawn parts.
@@ -402,6 +426,10 @@ pub(super) fn build_parts(
forms: &mut benilla_world::model_forms::ModelForms,
asset_server: &AssetServer,
mats: &mut benilla_world::model_render::M2BatchMaterials,
// Where this display's animated texture transforms are delivered (decision 2295) — taken
// rather than reached for, because building an entity batch's material and putting it on the
// lane are one act.
uv: &mut benilla_world::model_render::EntityUvLane<'_>,
// Whether this display is a GAMEOBJECT: gates the hull-collider build (chests/veins/doors;
// creatures use unit collision).
gameobject: bool,
@@ -527,7 +555,7 @@ pub(super) fn build_parts(
// the law that decides which of them collapse onto each other (decisions 0842
// / 0865 / 0831 / 0355). `None` cannot happen here: the caller gated on the
// light buffer before the first build.
let v = mats.entity_variants(sub, texture, order);
let v = mats.entity_variants(sub, texture, order, uv);
let v = v.expect("light buffer checked at entry");
EntityPart {
// Index-parallel with the submeshes by the forms contract; a miss is a
@@ -554,6 +582,7 @@ pub(super) fn build_parts(
welded_billboard: sub.geometry.welded_billboard,
alpha_anim: sub.alpha_anim.clone(),
rgb_anim: sub.rgb_anim.clone(),
rgb_seq: sub.rgb_seq.clone(),
uv_anim: sub.uv_anim.clone(),
uv_seq: sub.uv_seq.clone(),
uv_rot_seq: sub.uv_rot_seq.clone(),
@@ -608,6 +637,7 @@ pub(super) fn build_parts(
welded_billboard: false, // …so nothing can be welded to one
alpha_anim: None, // …nor colour/weight loops
rgb_anim: None,
rgb_seq: None,
uv_anim: None, // …nor texture transforms (WMO has none)
uv_seq: None,
uv_rot_seq: None,
@@ -645,6 +645,14 @@ fn spawn_slot(
benilla_world::doodad_anim::MatAnim::resting(anim.clone())
});
}
// …and the batch's texture transform (decision 2295), on the same one predicate every
// other entity spawn asks. One held-item batch in the whole 1.12 corpus animates one —
// `Item\ObjectComponents\Shield\shield_epic_a_01`, the Drillborer Disk, whose
// Ragnaros-skinned lava is a 6.7 s global-sequence scroll — which is exactly why the
// marker is a property of the batch here and not of a list somebody maintains.
if part.uv_loops().animates() {
child.insert(benilla_world::doodad_anim::AnimMatPart);
}
effective.dress(&mut child, &set);
}
});
@@ -716,6 +724,9 @@ fn spawn_slot(
if let Some(anim) = &part.alpha_anim {
card.insert(benilla_world::doodad_anim::MatAnim::resting(anim.clone()));
}
if part.uv_loops().animates() {
card.insert(benilla_world::doodad_anim::AnimMatPart);
}
effective.dress(&mut card, &set);
}
// The item's own particle emitters — the held torch's flame (0130 phase 4: the same
@@ -911,6 +922,7 @@ mod tests {
billboard: None,
alpha_anim: None,
rgb_anim: None,
rgb_seq: None,
uv_anim: None,
uv_seq: None,
uv_rot_seq: None,
@@ -178,18 +178,6 @@ pub(crate) struct FxMaterials<'a> {
pub(crate) table: &'a mut benilla_world::mat_anim_table::MatAnimTable,
}
/// This part's four baked texture-transform channels, in the shape the engine's effect lane takes
/// them (`FxUvLoops::any` is the "does it animate at all" test — a scale-only transform and a
/// dead-slot-0 translation each answer `None` to the obvious channel).
fn part_uv_loops(part: &EntityPart) -> benilla_world::doodad_anim::FxUvLoops {
benilla_world::doodad_anim::FxUvLoops {
seqs: part.uv_seq.clone(),
single: part.uv_anim.clone(),
rot: part.uv_rot_seq.clone(),
scale: part.uv_scale_seq.clone(),
}
}
/// Resolve one part's material for a NEW effect instance. The shared handle, unless a channel has
/// to run **per instance** — an animated M2Color RGB (decision 0271) or a texture transform
/// (decision 2282) — and then a clone of it, seeded at each live loop's first key and registered
@@ -201,7 +189,7 @@ fn fx_part_material(
host: Entity,
mats: &mut FxMaterials,
) -> Handle<WowModelMaterial> {
let loops = part_uv_loops(part);
let loops = part.uv_loops();
if part.rgb_anim.is_none() && !loops.any() {
return part.material.clone();
}
+11 -1
View File
@@ -834,6 +834,16 @@ pub(crate) mod schedule_tests {
/// - **2282:** the three fx attaches against the mat-anim tick and its probe, over
/// `MatAnimTable`/`UvAnimMaterials` — immaterial by construction, because attach writes
/// the rows a tick would, off the same clock.
/// - **2295:** the same two readers against `entities::update_display_models` and
/// `entities::attach::attach_entity_visuals`, over the same two resources, now that the
/// entity lane registers its own texture transform. **+4**, measured pair by pair on the
/// rebased tree rather than assumed additive — both writers already held
/// `Assets<WowModelMaterial>`, so most of the systems they newly meet were already
/// ambiguous against them for another reason, and `redress_player_looks` takes the
/// identical three resources and adds no pair at all. Immaterial for 2282's reason plus
/// one: a display is built ONCE, the frame its asset lands, and a row left unwritten for
/// a frame reads as the material's own built seed — the batch's authored `t = 0`, not a
/// wrong value — because the same call seeds `sun_scale.zw` at the loop's opening.
///
/// **3,280 (decision 2288)** — the one query cache: a read that asks marks its miss through
/// `&self`, so a feed that only resolves a name or a template holds the owner shared, and
@@ -844,7 +854,7 @@ pub(crate) mod schedule_tests {
/// Raising this ceiling is a claim that a new undeclared order is acceptable; make it with
/// the reason, or declare the order instead (`.after`, a set, a `chain`). If the pair is
/// about a resource that commutes by construction, the claim belongs in [`Classes`].
const UPDATE_ACTIONABLE_CEILING: usize = 3_280;
const UPDATE_ACTIONABLE_CEILING: usize = 3_284;
const UPDATE_ACTIONABLE_SLACK: usize = 40;
fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) {
+31 -2
View File
@@ -449,7 +449,7 @@ fn is_instrument_consumer(rel: &str) -> bool {
/// registration without a row, or a row without a registration, is that same shape. One verb
/// cannot be half-taken. It is also why the three pieces went back to `pub(crate)` in the same
/// commit, which is the rare crossing that *lowers* the surface it replaces.
/// And 183 → 184: `doodad_anim::FxUvLoops`, the argument of the verb above. A texture transform is
/// And 183 → 184: `doodad_anim::UvLoops`, the argument of the verb above. A texture transform is
/// ONE authored record with four baked channels — 1408's two translation spellings plus 2019's
/// per-slot rotation and scaling — and which of them a batch fills is not a thing the caller gets
/// to reason about: a scale-only transform (`Spells\GroundingTotem_Impact.mdx`) and a
@@ -459,7 +459,36 @@ fn is_instrument_consumer(rel: &str) -> bool {
/// in the engine, where the bake's rules live, instead of copied into the effect attach where they
/// would drift. Four positional `Option`s would have been the alternative, and swapping two of
/// them is a silent wrong-channel bug the compiler cannot see.
const CEILING: usize = 184;
/// And 184 → 186, both for the ENTITY lane (decision 2295), which is the same crossing 2282 made
/// for the effect lane and made for the same reason.
/// `doodad_anim::register_entity_uv` is the second PUBLISH verb on this lane: put a unit /
/// GameObject / held-item batch material on the UV lane, picking the shared clock or the
/// instance's play head from the authored record rather than making the caller reason about it.
/// It is a separate verb from `register_fx_uv` and not a flag on it because the two lanes differ
/// in the one thing a caller cannot get right by accident — an effect's clocks are measured from
/// its own attach (0856/0858) and a resident entity's are the scene's — and a boolean spelling of
/// that would read as a preference.
/// `model_render::EntityUvLane` is the argument `entity_variants` grew, and the crossing it buys
/// is the point of the decision: building an entity batch's material and putting it on the lane
/// are ONE act, so the engine takes the registry and the delta table *in the same call* instead of
/// handing back six handles and trusting the game to register them. That is 2038's law spelled in
/// the signature — a `play_uv` flip without a registration freezes every entity batch at its first
/// key instead of its identity, which is a different wrong frame and not a fix — and it is why the
/// lane is a bundle rather than three arguments a caller can pass two of.
/// And 186 → 189, the second half of 2295 — the ONE entity population that needs a material of
/// its own rather than the batch's, which the dressing path clones and registers because the
/// clone has to exist before the part's interior and fade records are built from it.
/// `doodad_anim::AnimMatPart` is the draw-scan marker, and it crosses for the reason 1375 put it
/// at the spawn site in the first place: the marker and the registration are one predicate, and
/// the game is where an entity part is spawned. The engine cannot insert it — it does not know
/// an entity part exists — and a lane that let the two drift is 2038's three frozen days.
/// `doodad_anim::register_tint` and `doodad_anim::TintLoop` are the tint channel's twins of the
/// UV verbs above, for the same five batches on three GameObject models: `G_FreezingTrap`'s glow
/// card, `OrgrimmarPentagram`, `ScholomanceCrystalBall01` — four of whose five batches bake
/// **nothing** in file slot 0, so a shared material can only ever seed white however faithfully it
/// is ticked. They were already `pub` for the world streamer; what crosses here is the game naming
/// them, and it is the same crossing the UV half makes one line up.
const CEILING: usize = 189;
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
@@ -493,9 +493,9 @@ enum Command {
///
/// Decision 0271 deferred this channel on the claim that "no effect model in the current
/// corpus needs it"; this is that claim, made countable — it is what 2282 read to size the
/// missing scroll, and it is how the same question is re-asked of the lanes that still run
/// none of it (the unit and GameObject batches, which `entity_variants` still builds with
/// `play_uv = false`). It asks the **bake**, not a transcription of it: a batch is in scope
/// missing scroll, and it is how the same question was re-asked of the unit / GameObject /
/// held-item corpus that 2295 then fixed (`entityuvscan`, its twin). It asks the **bake**,
/// not a transcription of it: a batch is in scope
/// exactly when `tex_anim` emitted a loop on any of the three channels, the same test the
/// lanes that DO run them use (`ui_models` 2019, `spell_fx` 2282).
///
@@ -523,6 +523,83 @@ enum Command {
/// models if omitted.
prefix: Option<String>,
},
/// Sweep every model the **ENTITY lane** can render — every `CreatureDisplayInfo` →
/// `CreatureModelData` body (NPCs, critters, mounts, and every PLAYER, which resolves through
/// the same chain, decision 0041), every `GameObjectDisplayInfo` model, every
/// `ItemDisplayInfo` left/right model joined to the `Item\ObjectComponents\` folder the
/// archives actually hold it in, and the corpse lane's `<Race><Sex>DeathSkeleton` bone piles —
/// and census the batches whose **texture transform animates**, then classify each by what a
/// consumer that runs NONE of it renders.
///
/// The twin of `Fxuvscan`, asked of the corpus that record's fix did NOT reach. Decision 2282
/// gave the spell-effect lane its texture transform and named this as its first deferral:
/// `model_render::batch::Materials::entity_variants` passed `play_uv = false`, and `build`
/// does `play_uv.then_some(sub.uv_anim.as_ref()).flatten()` — so every unit, player,
/// GameObject and held-item batch was handed no loop and drew its authored UVs, untransformed,
/// for ever. Nobody had measured how much content that is; this is that measurement, it is
/// what sized **decision 2295**, which closed it, and it shares `fxuvscan`'s whole per-batch
/// reader (`uv_batch`) so the two corpora cannot be judged by two different rules. It stays
/// because the census is how the claim "this lane needs no channel" is kept checkable — which
/// is the one thing 0271's deferral was missing.
///
/// **Read the corpus from the tables, never from a path prefix.** "The path looks broken" and
/// "the path is reachable" are different questions and only the second one is worth a session
/// (2282's own closing note). A held item's directory is not even a column — the equipment lane
/// picks `Weapon`/`Shield`/`Shoulder`/`Head`/`Ammo`/`Quiver` from the slot the item is worn in
/// — so the sweep asks the archives which folder holds each display's basename, and expands a
/// helm stem into its sixteen per-race/sex files the way the attach does.
///
/// The classes are `fxuvscan`'s, judged the same way — from the texture's own alpha through the
/// batch's authored address mode (INVISIBLE / NEVER / FROZEN / HELD / UNKNOWN; that command's
/// doc is where they are explained) — with one thing this corpus adds: a CREATURE batch's
/// `Monster1/2/3` sheet is **blank in the M2** and filled per display from
/// `CreatureDisplayInfo.textureVariation`, so one batch has as many sheets as the model has
/// skins and the verdict is asked once per skin. A character composite (body atlas, hair,
/// object skin) has no authored sheet at all and reads UNKNOWN, which is the honest answer
/// rather than a silent pass.
///
/// **The load-bearing column is the CLOCK**, because it decides the SHAPE of the fix and not
/// merely its size:
///
/// - **GSEQ** — every live loop rides a global sequence, a free-running per-scene clock the
/// reference anchors once per instance at attach. One shared material uniform is faithful
/// there; that is 0136 choice 1's lane, already built.
/// - **BAND** — every live loop rides its sequence band, i.e. the instance's own play head. Two
/// units playing different animations, or the same one at different phases, are at different
/// offsets, and no shared uniform can serve both: that is 2282's per-instance `UvLoop`.
/// - **MIXED** / **HOLD** — the batch's channels disagree, or it is keyed to a constant
/// non-identity offset that needs a seed and no clock at all.
///
/// Each affected batch prints its three channels (translation / rotation / scaling — a sweep
/// that looked only at translation would have missed `GroundingTotem_Impact` entirely, which is
/// scale-only), its per-axis texel reasoning, both frozen verdicts, its clock, which file
/// sequence slots carry the keys and what those sequences are *called*, and — where the bake
/// refused the shared lane — `uvslotscan`'s own `uniform()` verdict for the same set. The
/// report closes with every affected model sorted by POPULATION: how many table rows can put it
/// on screen, which is the difference between one gnome terminal and every murloc in the game.
///
/// **And then the same corpus again for the M2COLOR TINT**, because the UV channel is not the
/// only one this lane drops. `build` passes `sub.rgb_anim.as_ref()` *unconditionally* — there
/// is no `play_rgb` to flip — so a tint-animating entity batch is seeded at the loop's first
/// key and then never re-sampled, because `doodad_anim::register_tint`'s only call sites (like
/// `register_uv`'s) are in the world streamer's `assemble.rs`. The hole is the missing
/// REGISTRATION, not a missing argument, which is why flipping `play_uv` alone would only
/// trade one frozen frame for another.
///
/// A tint is a **multiply**, so its failure mode is the wrong colour rather than missing
/// geometry, and the classes say how wrong: BLACK (the frozen tint kills the batch), STRONG,
/// SLIGHT, NEGLIGIBLE — bands over one printed number, the worst per-channel distance between
/// the frozen value and anything the loop reaches. Two qualifications the raw count would
/// overstate without: a batch whose slot 0 bakes nothing seeds **white**, so it is the right
/// colour until a later slot's animation plays and wrong only *during* it (the `slots` line
/// names which); and the **ALPHA** channel is counted beside them precisely because this lane
/// DOES serve it — `attach::dress::spawn_part` gives every part a `MatAnim` that
/// `sample_mat_anim` ticks per instance. One of the three material-animation channels runs.
Entityuvscan {
/// Internal-path prefix filter (e.g. `creature`), case-insensitive; all reachable entity
/// models if omitted.
prefix: Option<String>,
},
/// Sweep every `.m2` (optionally under a path prefix) and census the batches whose texture
/// coordinates are **GENERATED, not authored** — the sphere-map environment stages
/// (`texture_unit_lookup[texCoordSet] > 2`, the reference's gate at `0x70b8bd`). Such a batch
@@ -982,6 +1059,7 @@ fn main() -> Result<()> {
Command::Seqclockscan { prefix } => scan::seqclockscan(&mut chain, prefix.as_deref())?,
Command::Uvwrapscan { prefix } => scan::uvwrapscan(&mut chain, prefix.as_deref())?,
Command::Fxuvscan { prefix } => scan::fxuvscan(&mut chain, prefix.as_deref())?,
Command::Entityuvscan { prefix } => scan::entityuvscan(&mut chain, prefix.as_deref())?,
Command::Envmapscan { prefix } => scan::envmapscan(&mut chain, prefix.as_deref())?,
Command::Texmodescan { prefix } => scan::texmodescan(&mut chain, prefix.as_deref())?,
Command::Fxordercensus { prefix } => scan::fxordercensus(&mut chain, prefix.as_deref())?,
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,8 @@
//! - [`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,
//! the batches whose UV/tint loop differs between sequence slots, and what the spell-effect
//! corpus's animated texture transforms render for a consumer that runs none of them.
//! (`fxuvscan`) and unit/GameObject/held-item (`entityuvscan`) corpora's animated texture
//! transforms render for a consumer that runs none of them.
//! - [`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, what breaks when it is the wrong one, and
@@ -33,7 +34,7 @@ mod world;
pub use geometry::{animboundscan, bbfacescan, bbscan, geosetscan, groundscan, normalscan};
pub use lighting::{darkpropscan, m2lightscan, shadeat};
pub use material::{
alphascan, blendscan, envmapscan, fxuvscan, texmodescan, uvslotscan, uvwrapscan,
alphascan, blendscan, entityuvscan, envmapscan, fxuvscan, texmodescan, uvslotscan, uvwrapscan,
};
pub use particles::{
cellscan, fxordercensus, partcensus, partscan, partslotscan, ribbonscan, shardcensus,
+1 -1
View File
@@ -113,7 +113,7 @@ mod tests {
let displays = crate::load_item_display_catalog(&mut chain).expect("load ItemDisplayInfo");
let mut nonzero = 0usize;
let mut valid = 0usize;
for d in displays.iter() {
for (_, d) in displays.iter() {
if d.group_sounds != 0 {
nonzero += 1;
if sounds.groups.contains_key(&d.group_sounds) {
+1 -1
View File
@@ -387,7 +387,7 @@ mod tests {
let displays = crate::load_item_display_catalog(&mut chain).expect("load ItemDisplayInfo");
let (mut carried, mut minus_one, mut resolved, mut models) = (0, 0, 0, 0);
for d in displays.iter() {
for (_, d) in displays.iter() {
if d.item_visual == 0 {
continue;
}
+6 -3
View File
@@ -164,9 +164,12 @@ impl ItemDisplayCatalog {
self.displays.is_empty()
}
/// Iterate the rows (order unspecified) — the cross-DBC join checks use it.
pub fn iter(&self) -> impl Iterator<Item = &ItemDisplay> {
self.displays.values()
/// Iterate `(displayId, row)` (order unspecified) — the cross-DBC join checks use it, and so
/// does the corpus sweep that has to name *which* display reaches a model
/// (`benilla-extract entityuvscan`). The id is carried because a census that cannot join back
/// to a row id answers "how many" and never "which one".
pub fn iter(&self) -> impl Iterator<Item = (u32, &ItemDisplay)> {
self.displays.iter().map(|(&id, d)| (id, d))
}
}
@@ -115,6 +115,15 @@ impl AlphaAnim {
self.seq(seq).sample(elapsed, shared_now)
}
/// Every file sequence slot's pair, file order — the census instrument's read
/// (`benilla-extract entityuvscan`), the twin of [`SeqLoops::slots`] and there for the same
/// reason: an instrument that re-implements the per-slot walk can drift from the one the
/// runtime takes. Without it a sweep cannot even tell how many slots a batch was baked across,
/// because [`Self::seq`] degrades an out-of-range slot to slot 0 rather than refusing it.
pub fn slots(&self) -> &[AlphaSeq] {
&self.per_seq
}
/// Whether ANY sequence can drive this batch's alpha to zero — i.e. whether the batch is ever
/// culled. The spawn-side gate for "does this part need a live sampler at all".
pub fn ever_hides(&self) -> bool {
+121 -5
View File
@@ -122,6 +122,37 @@ fn sound_entries_schema() -> Schema {
s
}
/// Join one variation's `DirectoryBase` and `File[i]` into the path the archive is asked for —
/// **the reference's own rule, byte-for-byte** (`0x45be10`, the sole caller `0x45c167` inside the
/// `SOUNDDEFINITION` loader; wow-re `sound/scratch/soundentries-path-join.md`).
///
/// The client formats `"%s%s%s"` over `(dir, sep, file)` and chooses `sep` with exactly two tests:
/// it is the empty string when `DirectoryBase` is **NULL/empty**, and when `SStrChrR(dir, '\')`
/// says the directory **already ends in a separator**; otherwise it is `"\"`. That is the whole
/// normalization in the client, at this layer or any other — and it has to be spelled here,
/// because the archive layer below rescues nothing: `HashString` folds ASCII `a`–`z` to upper and
/// maps `/` → `\` per character into the hash, and does **not** strip a leading separator, collapse
/// a doubled one, or touch a trailing one (wow-re `mpq.md`, an evidenced absence — the hash loop
/// read end to end). There is no loose-file fallback either: `SFileOpenFileEx`'s disk diversion
/// takes `\\`, `X:` and a flag WoW's own startup clears, never a single leading `\`.
///
/// **27 of the 5875 data's 8961 variations do not resolve, and the reference cannot play them
/// either** — 17 are simply absent from the archives (`GhostMusic01/02`, `mOgreFidget3`, and ids
/// 195/196, where `mHumanFemaleWoundVoxA.wav` was authored across two `File` cells as
/// `mHumanFemaleWoundVoxA` and `wav`), and the other 10 are kit **8940 `Ashbringer`**, whose
/// `DirectoryBase` is `\Sound\Creature\Ashbringer\`: the client suppresses the *trailing*
/// separator and then emits the *leading* one verbatim, so the path it opens
/// (`\Sound\Creature\Ashbringer\ASH_SPEAK_01.wav`) misses in every archive although the asset is
/// there. The Ashbringer's speak lines are dead data in the real client, and are meant to stay
/// dead here: a leading-separator strip would be a deviation, not a fix.
fn join_variation(dir: &str, file: &str) -> String {
if dir.is_empty() || dir.ends_with('\\') {
format!("{dir}{file}")
} else {
format!("{dir}\\{file}")
}
}
/// Read SoundEntries.dbc off the patch chain into a [`SoundKitCatalog`].
pub fn load_sound_kit_catalog(chain: &mut Chain) -> Result<SoundKitCatalog> {
let bytes = chain
@@ -140,11 +171,7 @@ pub fn load_sound_kit_catalog(chain: &mut Chain) -> Result<SoundKitCatalog> {
continue;
};
let weight = u32_at(r, 13 + i).unwrap_or(0);
let path = if dir.is_empty() {
file
} else {
format!("{dir}\\{file}")
};
let path = join_variation(&dir, &file);
files.push((path, weight));
}
if !name.is_empty() {
@@ -253,4 +280,93 @@ mod tests {
"creature barks split wet/dry"
);
}
/// The join's three legs, in the reference's own order (`0x45be10`): an empty directory emits
/// the file verbatim (26 shipped rows, e.g. 1103 `WyvernWingFlap`, whose variations are
/// themselves full paths), a directory that already ends in a separator gets none added, and
/// everything else gets exactly one. A **leading** separator is carried through untouched —
/// the client normalizes nothing there, and neither do we.
#[test]
fn the_variation_join_is_the_reference_s_separator_rule() {
assert_eq!(
join_variation("Sound\\Spells", "Dispel_Low_Base.wav"),
"Sound\\Spells\\Dispel_Low_Base.wav"
);
assert_eq!(
join_variation("Sound\\interface\\", "igNewTaxiNodeDiscovered.wav"),
"Sound\\interface\\igNewTaxiNodeDiscovered.wav"
);
assert_eq!(
join_variation("", "Sound\\Creature\\Wyvern\\WyvernWingFlap1.wav"),
"Sound\\Creature\\Wyvern\\WyvernWingFlap1.wav"
);
assert_eq!(
join_variation("\\Sound\\Creature\\Ashbringer\\", "ASH_SPEAK_01.wav"),
"\\Sound\\Creature\\Ashbringer\\ASH_SPEAK_01.wav",
"the leading separator survives — the reference emits it and misses too"
);
}
/// **Every kit path this loader builds, asked of the real chain.** The numbers are the
/// reference's own: the binary's join resolves 8934 of 8961 variations, and the 27 it does not
/// are authentic dead data. An unconditional `format!("{dir}\\{file}")` — what this loader did
/// before the join was derived — resolves exactly three fewer, and those three are whole kits:
/// 1519 `TaxiNodeDiscovered`, 2988 `Unarmed Small`, 3412 `Pirate Agro` each carry one variation
/// and a `DirectoryBase` that already ends in a separator, so every one of them was silent.
///
/// The test pins both sides — what resolves and what is *meant* not to — so neither a
/// regression nor an over-eager "fix" of the Ashbringer can land quietly. Skips without client
/// data.
#[test]
fn real_sound_entries_paths_resolve_exactly_as_the_reference_s_do() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_sound_kit_catalog(&mut chain).expect("load sound kits");
let mut total = 0usize;
let mut dead: Vec<(u32, &str, &str)> = Vec::new();
for kit in cat.kits.values() {
for (path, _) in &kit.files {
total += 1;
if !chain.contains(path) {
dead.push((kit.id, &kit.name, path));
}
}
}
dead.sort_unstable();
assert_eq!(total, 8961, "non-empty variation cells in the 5875 table");
assert_eq!(
total - dead.len(),
8934,
"variations that resolve — the binary's own count; {dead:?}"
);
// The ten that are one kit, and the one kit that is therefore silent — in the reference
// too (its `DirectoryBase` opens with a separator the client passes straight through).
let ashbringer: Vec<_> = dead.iter().filter(|(id, ..)| *id == 8940).collect();
assert_eq!(ashbringer.len(), 10, "every ASH_SPEAK line misses");
assert!(
chain.contains("Sound\\Creature\\Ashbringer\\ASH_SPEAK_01.wav"),
"the asset ships — it is the authored path that cannot reach it"
);
// …and the other 17 are ordinary absent assets, spread across kits that mostly still play.
assert_eq!(dead.len() - ashbringer.len(), 17, "absent assets: {dead:?}");
// No OTHER kit is fully silent through a path benilla built. 8588 is, and is meant to be:
// its single variation is simply not in the archives.
let silent: Vec<u32> = cat
.kits
.values()
.filter(|k| !k.files.is_empty() && !k.files.iter().any(|(p, _)| chain.contains(p)))
.map(|k| k.id)
.collect();
let mut silent = silent;
silent.sort_unstable();
assert_eq!(
silent,
vec![8588, 8940],
"the only kits with no playable variation at all"
);
}
}
@@ -44,9 +44,9 @@ fn a_modelless_head_display_is_not_a_worn_helm() {
);
// 3. A real helm still hides what it should — the change must not disarm the mechanism.
let helm = items
let (_, helm) = items
.iter()
.find(|d| d.model[0].is_some() && d.helmet_vis != [0, 0])
.find(|(_, d)| d.model[0].is_some() && d.helmet_vis != [0, 0])
.expect("some display is a modelled helm with a vis row");
assert_eq!(
helm.worn_helm_vis(),
@@ -57,10 +57,10 @@ fn a_modelless_head_display_is_not_a_worn_helm() {
// 4. The blast radius, pinned: of the 1314 rows that author a vis pair, exactly 12 leave the
// LEFT model slot empty. If the shipped data ever changes, this test says so rather than the
// change silently growing.
let masked = items.iter().filter(|d| d.helmet_vis != [0, 0]).count();
let masked = items.iter().filter(|(_, d)| d.helmet_vis != [0, 0]).count();
let masked_modelless = items
.iter()
.filter(|d| d.helmet_vis != [0, 0] && d.model[0].is_none())
.filter(|(_, d)| d.helmet_vis != [0, 0] && d.model[0].is_none())
.count();
assert_eq!((masked, masked_modelless), (1314, 12));
@@ -70,11 +70,11 @@ fn a_modelless_head_display_is_not_a_worn_helm() {
// reference's answer is the left slot's.
let right_only = items
.iter()
.filter(|d| d.model[0].is_none() && d.model[1].is_some())
.filter(|(_, d)| d.model[0].is_none() && d.model[1].is_some())
.count();
let right_only_masked = items
.iter()
.filter(|d| d.model[0].is_none() && d.model[1].is_some() && d.helmet_vis != [0, 0])
.filter(|(_, d)| d.model[0].is_none() && d.model[1].is_some() && d.helmet_vis != [0, 0])
.count();
assert_eq!((right_only, right_only_masked), (41, 0));
}
@@ -0,0 +1,165 @@
//! The **entity** corpus's material animation (decision 2295) — the four shapes the unit /
//! GameObject / held-item lane has to serve, each pinned on the asset that makes it unavoidable.
//!
//! Decision 0130 phase 3 built the texture transform for placed doodads and left every other lane
//! off it; 2282 put the spell effects on; this file is the entity lane's turn, and it exists for
//! the same reason 2282's does — *the premise that a lane needs no channel is a measurement, and an
//! unmeasured one is how a channel gets quietly dropped.* `benilla-extract entityuvscan` is the
//! census; these are the four rows of it a reader has to be able to check by hand.
//!
//! Skips (passes) when the client isn't present at `<repo>/WoW/Data`.
use benilla_formats::{open_chain, parse_m2_render_submeshes};
/// The whole entity corpus's widest case: **a creature, on a global sequence.** Every wind
/// elemental in the game is this model, and 26 of its 32 batches slide a full sheet across
/// themselves on a free-running clock — which is the one case where a shared material uniform is
/// not a compromise but exactly what the reference does (0136 choice 1: the reference clocks a
/// global-sequence loop on one per-scene ms cursor).
#[test]
fn a_creature_scrolls_on_a_global_sequence() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let bytes = chain
.read_file("Creature\\AirElemental\\AirElemental.m2")
.expect("AirElemental is in the chain");
let subs = parse_m2_render_submeshes(&bytes, "Creature\\AirElemental", &[]).expect("parse");
let live: Vec<_> = subs
.iter()
.filter_map(|s| s.uv_anim.as_ref())
.filter(|a| a.period > 0.0)
.collect();
assert_eq!(live.len(), 26, "the banded shell's animating batches");
assert!(
live.iter().all(|a| a.gseq),
"every one rides a GLOBAL sequence — no host, no play head, a shared clock is faithful"
);
// A full sheet per loop: the batches slide u by 1.0 over their period, which is what makes the
// shell read as moving air rather than as a painted band.
for a in &live {
let (lo, hi) = a
.keys
.iter()
.fold((f32::MAX, f32::MIN), |(lo, hi), (_, v)| {
(lo.min(v[0]), hi.max(v[0]))
});
// A whole sheet, to the key quantization the exporter left behind (one batch ends at
// 1.0017): what matters is that a full repeat passes under the geometry each loop.
assert!(
(hi - lo - 1.0).abs() < 5e-3,
"a whole sheet of U per loop: [{lo}, {hi}]"
);
}
// Nothing here is per-sequence, so these batches keep the shared, deduped material.
assert!(subs.iter().all(|s| s.uv_seq.is_none()));
}
/// **A batch that rotates and never translates.** Both of the corpus's two live on GameObjects,
/// and both answer `None` on the channel a reader checks first — so a lane whose predicate is
/// `uv_anim.is_some()`, or whose row carries only a translation, drops them without a trace. This
/// is `UvLoops::animates`' whole reason for existing, and 2019's affine row's.
#[test]
fn a_gameobject_batch_rotates_with_no_translation_at_all() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open vanilla patch chain");
for (path, batches) in [
("World\\Goober\\G_ScryingBowl.m2", 1usize),
("World\\Goober\\G_ScourgeRuneCircleCrystal.m2", 1),
] {
let bytes = chain.read_file(path).expect("in the chain");
let dir = path.rsplit_once('\\').expect("a directory").0;
let subs = parse_m2_render_submeshes(&bytes, dir, &[]).expect("parse");
let turning: Vec<_> = subs.iter().filter(|s| s.uv_rot_seq.is_some()).collect();
assert_eq!(turning.len(), batches, "{path}: the rotating batches");
for s in &turning {
assert!(
s.uv_anim.is_none() && s.uv_seq.is_none(),
"{path}: no translation channel at all — the obvious test answers None"
);
assert!(s.uv_scale_seq.is_none(), "{path}: and no scaling either");
let rot = s.uv_rot_seq.as_ref().expect("checked");
let l = rot.seq(None).expect("slot 0 animates");
assert!(l.period > 0.0 && l.keys.len() > 1, "{path}: a live turn");
}
}
}
/// **The slots disagree, so no shared material can be right.** `BloodOfHeroes` authors its five
/// bubble sheets twice over one animation id — file slot 0 holds them still, slot 1 sweeps them —
/// at `freq` 16384/16383, so its 114 placements bubble independently of each other. That is
/// 1408's population reached from the entity side: the instance needs a material of its own, and
/// the row has to read whichever slot the instance is playing.
#[test]
fn a_gameobjects_slots_bake_different_loops() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let path = "World\\Lordaeron\\Plagueland\\PassiveDoodads\\BloodOfHeroes\\BloodOfHeroes.m2";
let bytes = chain.read_file(path).expect("in the chain");
let dir = path.rsplit_once('\\').expect("a directory").0;
let subs = parse_m2_render_submeshes(&bytes, dir, &[]).expect("parse");
let split: Vec<_> = subs.iter().filter(|s| s.uv_seq.is_some()).collect();
assert_eq!(split.len(), 5, "the bubble sheets");
for s in &split {
assert!(
s.uv_anim.is_none(),
"no single loop serves both slots — which is why the shared lane registers nothing \
here and the bubbles never moved"
);
let seqs = s.uv_seq.as_ref().expect("checked");
let slots = seqs.slots();
assert_eq!(slots.len(), 2, "two file sequence slots");
assert!(slots[0].is_none(), "slot 0 holds the sheet still");
let live = slots[1].as_ref().expect("slot 1 sweeps it");
let hi = live.keys.iter().fold(f32::MIN, |hi, (_, v)| hi.max(v[1]));
assert!(
(live.period - 3.334).abs() < 1e-2 && (hi - 0.605).abs() < 1e-2,
"slot 1 sweeps v to {hi} over {}s",
live.period
);
}
// The pool itself is a sixth batch with no transform at all — so what was missing was never
// "the blood pool", it was the bubbling.
assert_eq!(
subs.iter().filter(|s| s.uv_seq.is_none()).count(),
1,
"the pool draws with no transform, and always did"
);
}
/// **The same shape on the TINT channel**, and worse: four of the corpus's five animated entity
/// tints bake *nothing* in file slot 0, so `rgb_anim` is `None` for them and a shared material can
/// only ever seed white however faithfully it is ticked. `G_FreezingTrap` is the one with play
/// frequency — the hunter's trap, whose glow card is meant to pulse blue-white as it arms, on the
/// `Custom0` clip the server rings.
#[test]
fn a_gameobjects_tint_is_keyed_in_a_later_slot_alone() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let bytes = chain
.read_file("World\\Goober\\G_FreezingTrap.m2")
.expect("in the chain");
let subs = parse_m2_render_submeshes(&bytes, "World\\Goober", &[]).expect("parse");
let tinted: Vec<_> = subs.iter().filter(|s| s.rgb_seq.is_some()).collect();
assert!(!tinted.is_empty(), "the glow card's tint is per-sequence");
for s in &tinted {
assert!(
s.rgb_anim.is_none(),
"slot 0 bakes nothing — the shared lane's seed is white, for ever"
);
let seqs = s.rgb_seq.as_ref().expect("checked");
let slots = seqs.slots();
assert!(slots[0].is_none(), "…which is exactly what slot 0 holds");
let live = slots
.iter()
.enumerate()
.find_map(|(i, l)| l.as_ref().map(|l| (i, l)));
let (slot, l) = live.expect("some later slot keys the pulse");
assert!(
slot > 0 && l.period > 0.0 && l.keys.len() > 1,
"slot {slot}"
);
}
}
+270 -44
View File
@@ -258,7 +258,15 @@ pub struct AnimMatPart;
/// 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>),
///
/// `None` when the batch's transform carries **no translation at all** — a rotate-only or
/// scale-only transform, whose whole motion is the [`UvAnimEntry::affine`] row beside this
/// one. Two entity batches in the shipped corpus are exactly that
/// (`World\Goober\G_ScryingBowl`'s water and `G_ScourgeRuneCircleCrystal`'s rune ring, both
/// `chans —R—`), so a lane that made the translation mandatory would have to invent a
/// zero loop for them or drop them silently. The entry still holds its translation row; it
/// stays at zero, which is what that row means.
Shared(Option<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 {
@@ -301,9 +309,9 @@ pub struct UvAnimEntry {
pub anim: UvLoop,
pub slot: u16,
pub seed: [f32; 2],
/// The effect lane's **affine half** ([`FxAffine`], decision 2282) — `None` on every other
/// The effect lane's **affine half** ([`UvAffine`], decision 2282) — `None` on every other
/// lane, and on an effect batch whose transform only translates (the common case).
affine: Option<FxAffine>,
affine: Option<UvAffine>,
}
/// A texture transform's ROTATION and SCALING, on the effect lane: the two channels the
@@ -315,7 +323,7 @@ pub struct UvAnimEntry {
/// `Spells\GroundingTotem_Impact.mdx` is a **scale-only** transform, so a lane that ran only the
/// scroll would still render both wrong. `benilla-extract fxuvscan` is where that population is
/// read; it is three models today, which is exactly why nobody would have found them by eye.
struct FxAffine {
struct UvAffine {
rot: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 4]>>>,
scale: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
/// The affine row's slot (`anim_slots.z`). Its encoding is a delta from the IDENTITY, not
@@ -335,7 +343,7 @@ impl UvAnimEntry {
/// 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::Shared(anim) => anim.as_ref().map_or([0.0, 0.0], |a| a.sample(now)),
UvLoop::PerSeq { seqs, .. } => playing
.and_then(|(seq, band_t)| {
seqs.seq(Some(seq))
@@ -369,21 +377,29 @@ impl UvAnimEntry {
}
/// This entry's **affine** row and the slot it goes in, or `None` when the transform only
/// translates. Sampled on the same instance clocks [`Self::delta`] uses, so the three channels
/// of one transform are always read at one instant.
/// translates. Sampled on **the same clocks [`Self::delta`] uses for this entry's own lane**,
/// so the three channels of one transform are always read at one instant — a rotation read on
/// the scene clock beside a translation read on an instance's play head would be two different
/// moments of the same matrix.
pub(crate) fn affine(
&self,
now: f32,
gseq_now: f64,
playing: Option<(usize, f32)>,
) -> Option<(u16, [f32; 4])> {
let a = self.affine.as_ref()?;
let UvLoop::Instance { attached_at, .. } = &self.anim else {
return None; // the affine half is the effect lane's alone
// Per lane, exactly as `delta` does it: the shared lane reads the free-running scene clock
// on both, the per-placement lane its host's playing clip, and the effect lane its own
// age-anchored pair (decisions 0856/0858).
let (band_t, gseq) = match &self.anim {
UvLoop::Shared(_) => (now, gseq_now),
UvLoop::PerSeq { .. } => (playing.map_or(now, |(_, t)| t), gseq_now),
UvLoop::Instance { attached_at, .. } => {
let age = now - attached_at;
(playing.map_or(age, |(_, t)| t), f64::from(age))
}
};
let age = now - attached_at;
let band_t = playing.map_or(age, |(_, t)| t);
let seq = playing.map(|(seq, _)| seq);
let gseq = f64::from(age);
let q = a
.rot
.as_ref()
@@ -421,7 +437,7 @@ impl UvAnimEntry {
/// the same resolution [`Self::delta`] makes rather than a hand-rolled twin of it.
fn resolved(&self, playing: Option<(usize, f32)>) -> Option<&benilla_formats::UvAnim> {
match &self.anim {
UvLoop::Shared(a) => Some(a),
UvLoop::Shared(a) => a.as_deref(),
UvLoop::PerSeq { seqs, .. } => seqs.seq(playing.map(|(seq, _)| seq)),
UvLoop::Instance { seqs, single, .. } => seqs
.as_ref()
@@ -533,6 +549,41 @@ pub(crate) fn register_uv(
);
}
/// Attach the ROTATION/SCALING half to an entry [`register_uv`] has just made, if the batch has
/// one — the second table row, `anim_slots.z`, and 2019's `[cos − 1, sin, sx − 1, sy − 1]`
/// encoding whose zero row IS the identity.
///
/// The translation half decides whether there is an entry at all (a full table, a dead handle);
/// the affine half attaches to it or not at all. A transform that ONLY rotates or scales still
/// takes a translation row that stays at zero — one row, on a population of five models corpus-
/// wide, for a registration path with one shape instead of two.
fn attach_affine(
reg: &mut UvAnimMaterials,
table: &mut crate::mat_anim_table::MatAnimTable,
materials: &mut bevy::asset::Assets<benilla_assets::materials::WowModelMaterial>,
id: bevy::asset::AssetId<benilla_assets::materials::WowModelMaterial>,
rot: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 4]>>>,
scale: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
) {
if rot.is_none() && scale.is_none() {
return;
}
let Some(entry) = reg.0.get_mut(&id) else {
return;
};
match table.alloc() {
Some(slot) => {
crate::model_render::lazy::with_material_mut(materials, id, |mat| {
mat.extension.anim_slots.z = f32::from(slot);
});
entry.affine = Some(UvAffine { rot, scale, slot });
}
None => {
bevy::log::warn_once!("mat-anim table full — a UV rotation/scale stays at the identity")
}
}
}
/// Put one **spell-effect material clone** on the per-instance UV lane (decision 2282): the
/// engine's whole door for `entities::spell_fx`, which owns the clone and nothing else about how
/// the scroll is delivered.
@@ -552,11 +603,11 @@ pub fn register_fx_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>,
loops: FxUvLoops,
loops: UvLoops,
host: Entity,
attached_at: f32,
) {
let FxUvLoops {
let UvLoops {
seqs,
single,
rot,
@@ -575,52 +626,98 @@ pub fn register_fx_uv(
attached_at,
},
);
// The translation half decides whether there is an entry at all (a full table, a dead handle);
// the affine half attaches to it or not at all. A transform that ONLY rotates or scales still
// takes a translation row that stays at zero — one row, on a population of three models, for a
// registration path with one shape instead of two.
let Some(entry) = reg.0.get_mut(&id) else {
attach_affine(reg, table, materials, id, rot, scale);
let Some(entry) = reg.0.get(&id) else {
return;
};
if turns {
match table.alloc() {
Some(slot) => {
crate::model_render::lazy::with_material_mut(materials, id, |mat| {
mat.extension.anim_slots.z = f32::from(slot);
});
entry.affine = Some(FxAffine { rot, scale, slot });
}
None => bevy::log::warn_once!(
"mat-anim table full — an effect's UV rotation/scale stays at the identity"
),
}
}
let _ = turns;
// **Both rows, written now.** `tick_anim_materials` runs once per frame in `Update` and this
// registration happens in the same schedule, so an instance that attaches after the tick would
// otherwise draw its first frame at row 0 — the identity, which on this population is the
// authored-UV frame the whole decision is about (a Swipe strip renders NOTHING there). One
// write at spawn costs nothing and removes the class.
let (slot, row) = (entry.slot, entry.delta(attached_at, 0.0, None));
let affine = entry.affine(attached_at, None);
let affine = entry.affine(attached_at, 0.0, None);
table.set(slot, row);
if let Some((slot, row)) = affine {
table.set(slot, row);
}
}
/// Put one **unit / GameObject / held-item** batch material on the UV lane (decision 2295) — the
/// engine's whole door for the entity lane, and the twin of [`register_fx_uv`] for a population
/// that is resident rather than pooled.
///
/// It picks the lane from the authored record rather than making the caller reason about it,
/// which is the same argument [`UvLoops`] itself rests on:
///
/// - **`host` is `Some` and the slots disagree** ⇒ [`UvLoop::PerSeq`]. This material belongs to
/// that one instance (the caller owns the clone), and the loop is whichever file-sequence slot
/// the instance is playing — 1408's lane, reached from the entity side for the first time.
/// `World\Lordaeron\Plagueland\PassiveDoodads\BloodOfHeroes` is the class: slot 0 holds its
/// bubble sheets still while slot 1 sweeps them, at freq 16384/16383, so its placements bubble
/// independently and one shared row cannot serve two of them.
/// - **otherwise** ⇒ [`UvLoop::Shared`], the free-running scene clock on the batch's shared,
/// deduped material — 0136 choice 1. Exactly faithful for a global-sequence loop (the
/// reference clocks those on one per-scene ms cursor), and 0136's recorded, still-invisible
/// divergence for a seamless band loop.
///
/// A period-0 translation is not a loop — its seed IS its forever value, so registering it would
/// buy a per-frame re-write of the same number (1375) — but a batch that only rotates or scales
/// still registers, because its motion is the affine row and its translation row is the zero it
/// is meant to be.
pub fn register_entity_uv(
reg: &mut UvAnimMaterials,
table: &mut crate::mat_anim_table::MatAnimTable,
materials: &mut bevy::asset::Assets<benilla_assets::materials::WowModelMaterial>,
id: bevy::asset::AssetId<benilla_assets::materials::WowModelMaterial>,
loops: &UvLoops,
host: Option<Entity>,
) {
if !loops.animates() {
return;
}
let lane = match (loops.seqs.clone(), host) {
(Some(seqs), Some(host)) => UvLoop::PerSeq { seqs, host },
_ => UvLoop::Shared(loops.single.clone().filter(|l| l.period > 0.0)),
};
register_uv(reg, table, materials, id, lane);
attach_affine(
reg,
table,
materials,
id,
loops.rot.clone(),
loops.scale.clone(),
);
}
/// The four baked channels of one batch's texture transform, as [`register_fx_uv`] takes them —
/// 1408's two translation spellings plus 2019's per-slot rotation and scaling sets. A struct
/// because they are one authored record, and a caller passing four `Option`s positionally is a
/// caller that will eventually swap two of them.
#[derive(Default)]
pub struct FxUvLoops {
pub struct UvLoops {
pub seqs: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
pub single: Option<std::sync::Arc<benilla_formats::UvAnim>>,
pub rot: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 4]>>>,
pub scale: Option<std::sync::Arc<benilla_formats::SeqLoops<[f32; 2]>>>,
}
impl FxUvLoops {
impl UvLoops {
/// The record as one authored batch carries it — **the one place the four channels are read
/// off a submesh**, so a lane that registers and a spawner that marks can never disagree about
/// which batches animate (decision 2038's law, whose violation froze every waterfall in the
/// game for three days).
pub fn of(sub: &benilla_assets::ModelSubmesh) -> Self {
Self {
seqs: sub.uv_seq.clone(),
single: sub.uv_anim.clone(),
rot: sub.uv_rot_seq.clone(),
scale: sub.uv_scale_seq.clone(),
}
}
/// Does this batch animate its texture transform at all? The test the effect attach makes
/// before cloning a material: any of the four channels, because a scale-only transform
/// (`Spells\GroundingTotem_Impact.mdx`) and a dead-slot-0 translation (1408) each answer
@@ -629,6 +726,18 @@ impl FxUvLoops {
self.seqs.is_some() || self.single.is_some() || self.rot.is_some() || self.scale.is_some()
}
/// Does it need a per-frame SAMPLE — i.e. does it move? [`Self::any`] minus the constants: a
/// translation keyed to one value for the whole clip is its material's seed and nothing more
/// (1375), while a rotate-only or scale-only batch answers `None` on the translation channel
/// and still moves. The **one predicate** the entity lane's registration and its
/// [`AnimMatPart`] marker both ask.
pub fn animates(&self) -> bool {
self.seqs.is_some()
|| self.rot.is_some()
|| self.scale.is_some()
|| self.single.as_ref().is_some_and(|l| l.period > 0.0)
}
/// The offset the batch opens on — what a caller seeds the clone's `sun_scale.zw` with so its
/// first frame is the loop's own `t = 0` and not the authored, unscrolled UV.
pub fn open_offset(&self) -> [f32; 2] {
@@ -717,7 +826,7 @@ pub(super) fn tick_anim_materials(
// instance's frame-stepped `AnimationPlayer` — the `fxview` carve-out `MatAnim` and
// `spell_fx::FxTintAnims` already take (decision 2282). A capture with no effect in it — every
// golden scenario — holds no such entry, so it returns here exactly as it always has.
let frozen = crate::dev_state::deterministic_run();
let frozen = crate::dev_state::deterministic_run() && !matanim_live();
if frozen && !uv_reg.0.values().any(UvAnimEntry::instance_lane) {
return;
}
@@ -770,7 +879,7 @@ pub(super) fn tick_anim_materials(
table.set(entry.slot, entry.delta(now, gseq_now, playing));
// …and the rotation/scale row beside it, for the effect models that turn or scale
// their UVs rather than only sliding them (2282).
if let Some((slot, row)) = entry.affine(now, playing) {
if let Some((slot, row)) = entry.affine(now, gseq_now, playing) {
table.set(slot, row);
}
}
@@ -820,6 +929,22 @@ fn matanim_off(time: &Time<bevy::time::Real>) -> bool {
})
}
/// **`WOW_MATANIM_LIVE=1` — run the shared lanes inside a deterministic capture too.**
///
/// The freeze above is what makes a golden frame reproducible, and it is right for every scenario
/// in the sweep. It also means `fxview` — the fixture whose whole job is to age a subject — cannot
/// show a **unit's or GameObject's** texture transform at all, because those are shared-clock
/// entries (decision 2295); the effect lane is exempt only because its clock is one instance's
/// frame-stepped player. Without this knob the A/B for "does this creature's skin scroll" does not
/// exist, and the answer would have to come from the director's screen, which is not what §7 means
/// by leaving the look to them.
///
/// Off by default, read once, and no golden scenario sets it — so the sweep is untouched.
fn matanim_live() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_MATANIM_LIVE").is_ok_and(|v| v != "0"))
}
/// The **tint-animated materials** registry — the M2Color-RGB twin of [`UvAnimMaterials`]: each
/// batch material whose colour track animates (the vertex bake is skipped for those —
/// `benilla-formats` `m2_batches`), keyed by material asset id. [`tick_anim_materials`]
@@ -972,7 +1097,12 @@ pub(super) fn matanim_probe(
// The per-placement lane has one loop per sequence, so its phase sweep is meaningless
// without a play head — it prints the head instead, and samples on the shared clock.
let (lane, period) = match &e.anim {
UvLoop::Shared(a) => (format!("shared period {:.3}s", a.period), a.period),
UvLoop::Shared(a) => {
// `None` = a rotate-/scale-only transform: the translation row is real and stays
// at zero, and the period the sweep needs is the AFFINE channel's, not this one's.
let p = a.as_ref().map_or(0.0, |a| a.period);
(format!("shared period {p:.3}s"), p)
}
UvLoop::PerSeq { .. } => (format!("per-seq playing {playing:?}"), 0.0),
// The effect lane HAS a resolved loop (the clip it opened on), so unlike the
// per-placement lane its sweep is meaningful — read across the instance's own
@@ -1001,7 +1131,7 @@ pub(super) fn matanim_probe(
let live = e.delta(now, gseq_now, playing);
// The affine half, when the transform has one: `[cos − 1, sin, sx − 1, sy − 1]`, so all
// zeros IS the identity and a flat row there says the turn/scale never reached the shader.
let affine = e.affine(now, playing).map_or(String::new(), |(slot, want)| {
let affine = e.affine(now, gseq_now, playing).map_or(String::new(), |(slot, want)| {
let row = table.row(slot);
format!(
" · affine slot {slot} row ({:+.4},{:+.4},{:+.4},{:+.4}) live ({:+.4},{:+.4},{:+.4},{:+.4})",
@@ -1124,6 +1254,100 @@ mod delta_tests {
}
}
/// A rotation-only loop, as `G_ScryingBowl`'s water and `G_ScourgeRuneCircleCrystal`'s rune
/// ring author one: a quarter turn about Z over two seconds, keyed in file slot 0 only.
fn quarter_turn() -> std::sync::Arc<benilla_formats::SeqLoops<[f32; 4]>> {
const Q: f32 = std::f32::consts::FRAC_1_SQRT_2;
const QUARTER: [f32; 4] = [0.0, 0.0, Q, Q];
std::sync::Arc::new(
benilla_formats::SeqLoops::new(vec![Some(benilla_formats::KeyAnim {
period: 2.0,
step: false,
wrap: true,
gseq: false,
// 90° about Z = (0, 0, sin 45°, cos 45°).
keys: vec![(0.0, [0.0, 0.0, 0.0, 1.0]), (2.0, QUARTER)],
})])
.expect("one animating slot"),
)
}
/// **`animates()` is the one predicate, and it is not `uv_anim.is_some()`** (decision 2295).
/// Two entity batches in the shipped corpus rotate and never translate, so the channel a
/// reader would check first answers `None` for them; and a translation keyed to one constant
/// value is the material's seed, not a loop to sample (1375).
#[test]
fn the_animates_predicate_sees_a_rotation_only_batch() {
let rot_only = UvLoops {
rot: Some(quarter_turn()),
..Default::default()
};
assert!(rot_only.single.is_none(), "the obvious channel is empty");
assert!(rot_only.animates(), "and it still moves");
let constant = UvLoops {
single: Some(std::sync::Arc::new(benilla_formats::UvAnim {
period: 0.0,
step: false,
wrap: false,
gseq: false,
keys: vec![(0.0, [0.25, 0.0])],
})),
..Default::default()
};
assert!(constant.any(), "it has a channel");
assert!(
!constant.animates(),
"…but a period-0 translation is a seed, never a per-frame sample"
);
assert!(!UvLoops::default().animates());
}
/// **The affine half runs on whichever clock its own lane runs on** (decision 2295 widening
/// 2282's). A shared entity entry has no host and no attach time, so its rotation reads the
/// free-running scene clock — which is what makes a rune ring on a shared, deduped material
/// turn at all.
#[test]
fn a_shared_entry_turns_on_the_scene_clock() {
let e = UvAnimEntry {
anim: UvLoop::Shared(None),
slot: 4,
seed: [0.0, 0.0],
affine: Some(UvAffine {
rot: Some(quarter_turn()),
scale: None,
slot: 5,
}),
};
// No translation channel at all: the row it still owns stays at the zero it means.
assert_eq!(e.delta(1.0, 1.0, None), [0.0, 0.0, 0.0, 0.0]);
// Half-way through the turn, and the row is `[cos − 1, sin, sx − 1, sy − 1]` of the
// **raw, unnormalised** lerped quaternion — which is the reference's own arithmetic, not
// an oversight: `0x713ea0` lerps each component and `0x7bddb0` consumes the result as is,
// so between two keys `|q| < 1` and the 2×2 is a rotation with a slight shrink (wow-re
// `modelframe-texanim-and-sequence-law.md` §3.4: *"a re-implementation that slerps, or
// normalises before building the matrix, diverges from the reference between keys"*).
// Here `q = (0, 0, 0.35355, 0.85355)`, so `c = 1 − 2z² = 0.75` and `s = 2zw = 0.60355` —
// NOT the 0.7071/0.7071 a normalised 45° would give. Asserted exactly so a future
// "obvious fix" to normalise fails here.
let (slot, row) = e.affine(1.0, 1.0, None).expect("the affine row");
assert_eq!(slot, 5);
assert!(
(row[0] + 0.25).abs() < 1e-4 && (row[1] - 0.603_553).abs() < 1e-4,
"the unnormalised lerp at t = 1 s of a 2 s turn: {row:?}"
);
assert!(
row[2].abs() < 1e-6 && row[3].abs() < 1e-6,
"no scaling authored, so the scale half is the identity: {row:?}"
);
// …and the clock really is `now`: a different scene time gives a different angle.
let later = e.affine(1.5, 1.5, None).expect("the affine row").1;
assert!(
(later[1] - row[1]).abs() > 1e-2,
"the shared lane's affine must move with the scene clock: {row:?} vs {later:?}"
);
}
/// **The effect lane reads its INSTANCE's play head, not the scene clock** (decision 2282).
/// The band clock is the clip time the host's `AnimationPlayer` reports, so two casts a minute
/// apart are at the same point of their own scroll — the property a shared-material lane
@@ -1163,10 +1387,10 @@ mod delta_tests {
fn the_effect_lane_samples_rotation_and_scale_on_the_same_clock() {
let mut e = fx_entry(100.0);
assert!(
e.affine(100.5, Some((0, 0.5))).is_none(),
e.affine(100.5, 0.0, Some((0, 0.5))).is_none(),
"a translation-only transform takes no affine row"
);
e.affine = Some(FxAffine {
e.affine = Some(UvAffine {
rot: None,
scale: Some(std::sync::Arc::new(
benilla_formats::SeqLoops::new(vec![Some(benilla_formats::UvAnim {
@@ -1180,7 +1404,9 @@ mod delta_tests {
)),
slot: 12,
});
let (slot, row) = e.affine(100.0, Some((0, 1.0))).expect("the affine row");
let (slot, row) = e
.affine(100.0, 0.0, Some((0, 1.0)))
.expect("the affine row");
assert_eq!(slot, 12);
// Half-way: scale 0.625, encoded as `sx − 1`; rotation untouched, so `cos − 1` and `sin`
// are the identity's zeros.
@@ -1212,7 +1438,7 @@ mod delta_tests {
let anim = uv_loop();
let seed = anim.sample(0.0);
let entry = UvAnimEntry {
anim: UvLoop::Shared(anim.clone()),
anim: UvLoop::Shared(Some(anim.clone())),
slot: 3,
seed,
affine: None,
+2 -2
View File
@@ -41,8 +41,8 @@ mod mat_anim;
pub(crate) use lazy::{LazyRig, SkinnedTwin};
use mat_anim::tick_anim_materials;
pub use mat_anim::{
playing_seq, register_fx_uv, register_tint, sample_mat_anim, AnimMatPart, FxUvLoops, MatAnim,
TintAnimMaterials, TintLoop, UvAnimMaterials,
playing_seq, register_entity_uv, register_fx_uv, register_tint, sample_mat_anim, AnimMatPart,
MatAnim, TintAnimMaterials, TintLoop, UvAnimMaterials, UvLoops,
};
pub(crate) use mat_anim::{register_uv, UvLoop};
+1 -1
View File
@@ -17,7 +17,7 @@ pub mod lazy;
pub mod park;
mod visibility;
pub use batch::{BatchVariants, M2BatchMaterials, ModelMaterials, SkyboxBatch};
pub use batch::{BatchVariants, EntityUvLane, M2BatchMaterials, ModelMaterials, SkyboxBatch};
/// Yards of transparent-pass sort bias per authored batch-order step (`MatKey::batch_order` ×
/// this, on `StandardMaterial::depth_bias` — bevy adds that field to the phase item's sort
+59 -5
View File
@@ -58,6 +58,23 @@ pub struct BatchVariants {
pub zfill: Option<Handle<WowModelMaterial>>,
}
/// The **UV lane** an [`M2BatchMaterials::entity_variants`] call registers into (decision 2295) —
/// the animated-material registry, the delta table (decision 1381) and, for the one measured class
/// that needs it, the instance the materials belong to.
///
/// A bundle rather than three arguments because they are one thing — *where this batch's texture
/// transform is delivered* — and because the shape that keeps failing on this lane is a caller who
/// takes some of the pieces and not the rest.
pub struct EntityUvLane<'a> {
pub reg: &'a mut crate::doodad_anim::UvAnimMaterials,
pub table: &'a mut crate::mat_anim_table::MatAnimTable,
/// `Some` when these materials belong to ONE instance rather than to the batch — the clone a
/// spawner makes for a GameObject whose file-sequence slots bake different loops (1408's
/// population, reached from the entity side by 2295). `None` — the overwhelming majority — is
/// the shared, deduped material every instance of the batch draws through.
pub instance: Option<Entity>,
}
/// The pair one skybox batch draws with ([`M2BatchMaterials::skybox`]): the authored blend at full
/// weight, and the promoted SRC_ALPHA twin the 4-second crossfade rides (equal to `steady` when the
/// authored blend already blends).
@@ -239,11 +256,27 @@ impl M2BatchMaterials<'_> {
/// The full variant set an **entity** part needs: every M2 entity — unit, player, GameObject,
/// held item, spell effect — is built LIT and carries the same indoor pair, because the
/// reference hands every entity M2 the same entity-node fill (wow-re `unit-m2-shader-light`).
///
/// **It also puts the batch on the UV lane** (decision 2295), and takes `uv` for exactly that
/// reason rather than leaving it to the caller: seeding a material's `sun_scale.zw` and
/// registering it for the per-frame sample are one fact — *this batch's texture transform
/// runs* — and the recurring bug on this lane is a caller that does one and not the other
/// (2038 marked without registering and froze every waterfall; a `play_uv` flip without a
/// registration would freeze every entity batch at its first key instead of its identity,
/// which is a different wrong frame, not a fix). Every variant that carries the loop is
/// registered here, because a unit walking indoors or feathering in swaps material and would
/// otherwise stop scrolling mid-stride.
///
/// The **depth-prime twin is deliberately not registered**, matching the placed-doodad lane
/// (`terrain_stream::spawn::assemble` registers the cutout and blend materials and no other):
/// it draws only while a batch feathers, and its alpha test reading unscrolled UVs for those
/// frames is the behaviour the streamer has always had.
pub fn entity_variants(
&mut self,
sub: &ModelSubmesh,
texture: Option<Handle<Image>>,
order: u16,
uv: &mut EntityUvLane<'_>,
) -> Option<BatchVariants> {
let light = self.light.as_ref()?.0.clone();
let steady = self.build(
@@ -253,7 +286,7 @@ impl M2BatchMaterials<'_> {
ShadeSel::Lit,
false,
false,
false,
true,
&light,
);
let interior = self.build(
@@ -263,7 +296,7 @@ impl M2BatchMaterials<'_> {
ShadeSel::Matte,
false,
false,
false,
true,
&light,
);
let interior_bake = self.build(
@@ -273,7 +306,7 @@ impl M2BatchMaterials<'_> {
ShadeSel::Matte,
true,
false,
false,
true,
&light,
);
// A multiply batch (Mod/Mod2x) and an authored-Blend batch are their own twin: the first
@@ -296,7 +329,7 @@ impl M2BatchMaterials<'_> {
ShadeSel::Lit,
false,
true,
false,
true,
&light,
)
};
@@ -310,10 +343,31 @@ impl M2BatchMaterials<'_> {
ShadeSel::Matte,
true,
true,
false,
true,
&light,
)
};
// On the lane, now, in the same call that built and seeded them. `register_entity_uv` is
// idempotent per material, so the variants that collapsed onto each other cost nothing.
let loops = crate::doodad_anim::UvLoops::of(sub);
if loops.animates() {
for id in [
&steady,
&interior,
&interior_bake,
&interior_bake_blend,
&fade_blend,
] {
crate::doodad_anim::register_entity_uv(
uv.reg,
uv.table,
&mut self.materials,
id.id(),
&loops,
uv.instance,
);
}
}
Some(BatchVariants {
steady,
interior,
@@ -622,7 +622,7 @@ pub fn spawn_model_entities(
anim_table,
materials,
id,
crate::doodad_anim::UvLoop::Shared(anim.clone()),
crate::doodad_anim::UvLoop::Shared(Some(anim.clone())),
);
}
}