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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user