From 5f622e6fe875bd67f591543233e3ef7709cc4802 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 17 Sep 2026 16:42:33 +0700 Subject: [PATCH] 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 --- crates/benilla-app/src/entities.rs | 20 + .../benilla-app/src/entities/attach/dress.rs | 236 ++- .../benilla-app/src/entities/attach/merge.rs | 1 + crates/benilla-app/src/entities/attach/mod.rs | 24 +- .../src/entities/attach/redress.rs | 30 +- crates/benilla-app/src/entities/display.rs | 36 +- .../src/entities/equipment/spawn.rs | 12 + .../benilla-app/src/entities/spell_fx/mod.rs | 14 +- crates/benilla-app/src/game_plugins.rs | 12 +- crates/benilla-app/tests/world_api_wall.rs | 33 +- .../src/bin/benilla-extract/main.rs | 84 +- .../src/bin/benilla-extract/scan/material.rs | 1867 +++++++++++++++-- .../src/bin/benilla-extract/scan/mod.rs | 5 +- crates/benilla-formats/src/item_sounds.rs | 2 +- crates/benilla-formats/src/item_visuals.rs | 2 +- crates/benilla-formats/src/items.rs | 9 +- crates/benilla-formats/src/models/mat_anim.rs | 9 + crates/benilla-formats/src/sound_entries.rs | 126 +- .../tests/helm_vis_wants_a_helm.rs | 12 +- .../tests/m2_entity_mat_anim.rs | 165 ++ .../benilla-world/src/doodad_anim/mat_anim.rs | 314 ++- crates/benilla-world/src/doodad_anim/mod.rs | 4 +- crates/benilla-world/src/model_render.rs | 2 +- .../benilla-world/src/model_render/batch.rs | 64 +- .../src/terrain_stream/spawn/assemble.rs | 2 +- 25 files changed, 2765 insertions(+), 320 deletions(-) create mode 100644 crates/benilla-formats/tests/m2_entity_mat_anim.rs diff --git a/crates/benilla-app/src/entities.rs b/crates/benilla-app/src/entities.rs index 387129a9..a47894d7 100644 --- a/crates/benilla-app/src/entities.rs +++ b/crates/benilla-app/src/entities.rs @@ -1203,6 +1203,10 @@ fn update_display_models( mut forms: ResMut, asset_server: Res, 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, + mut anim_table: ResMut, // The bone-pile display cache + the ChrRaces fileStrings its paths are built from (1706). mut bones: ResMut, // 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, diff --git a/crates/benilla-app/src/entities/attach/dress.rs b/crates/benilla-app/src/entities/attach/dress.rs index 21bb040a..93a7399f 100644 --- a/crates/benilla-app/src/entities/attach/dress.rs +++ b/crates/benilla-app/src/entities/attach/dress.rs @@ -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, + 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, + interior: Option>, + fade_blend: Option>, + bake: Option>, + bake_blend: Option>, + zfill: Option>, +} + +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 { + 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| -> Option> { + 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, +) { + 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, 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, + 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(); diff --git a/crates/benilla-app/src/entities/attach/merge.rs b/crates/benilla-app/src/entities/attach/merge.rs index d32ebb61..9f9a3649 100644 --- a/crates/benilla-app/src/entities/attach/merge.rs +++ b/crates/benilla-app/src/entities/attach/merge.rs @@ -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, diff --git a/crates/benilla-app/src/entities/attach/mod.rs b/crates/benilla-app/src/entities/attach/mod.rs index 8b7bcda6..4ed7cac5 100644 --- a/crates/benilla-app/src/entities/attach/mod.rs +++ b/crates/benilla-app/src/entities/attach/mod.rs @@ -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>, ResMut, + // …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, + ResMut, + ResMut, ), // The owned skin-palette table (decision 0720): every skinned instance claims a rig slot. mut palettes: ResMut, @@ -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 diff --git a/crates/benilla-app/src/entities/attach/redress.rs b/crates/benilla-app/src/entities/attach/redress.rs index 77331deb..664127df 100644 --- a/crates/benilla-app/src/entities/attach/redress.rs +++ b/crates/benilla-app/src/entities/attach/redress.rs @@ -115,6 +115,15 @@ pub(in crate::entities) fn redress_player_looks( ResMut>, ResMut, ), + // 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, + ResMut, + ResMut, + ), time: Res