diff --git a/crates/benilla-app/src/entities/spell_fx/mod.rs b/crates/benilla-app/src/entities/spell_fx/mod.rs index cddbceb5..50449e44 100644 --- a/crates/benilla-app/src/entities/spell_fx/mod.rs +++ b/crates/benilla-app/src/entities/spell_fx/mod.rs @@ -724,6 +724,8 @@ pub(super) fn attach_spell_fx( Option<&mut benilla_world::rig_anim::RigPose>, &GlobalTransform, Has, + // The host's own box, for a host whose model has no attachment points at all. + Option<&crate::entities::ModelBound>, )>, fx: Option>, asset_server: Res, @@ -741,7 +743,7 @@ pub(super) fn attach_spell_fx( return; }; let now = time.elapsed_secs(); - for (unit, mut att, bones, mut pose, unit_gt, body_built) in &mut units { + for (unit, mut att, bones, mut pose, unit_gt, body_built, bound) in &mut units { att.instances.retain_mut(|inst| { // Self-termination (the cast-release flash ran its span). if let Some(expires) = inst.expires { @@ -838,7 +840,39 @@ pub(super) fn attach_spell_fx( }) }); let ground_anchor = point.is_none_or(|(tag, ..)| tag == 0x13); - let (parent, offset) = point.map_or((unit, Vec3::ZERO), |(_, j, o)| (j, o)); + // The cascade's last rung is the host's **origin**, and for a unit that is right: + // `0x13` is attachment 19, Base, which every creature model authors at the ground + // point under its feet, and the loot sparkle is a ground twinkle. A 1.12 + // **GameObject** model authors no attachment points at all + // (`copper_Miningnode_01.m2`: 0), so the cascade falls through to an origin that + // is the ground point *under* the object — inside its own mesh. Measured: with + // the sparkle armed on a copper vein, the frame is byte-identical to one with it + // gated off, because `Particles\LootFX.m2` is a ~1 yd twinkle (four emitters, + // 0.06–0.14 yd particles, ~0.9 yd reach) and the rock swallows every quad; the + // same sparkle on a peacebloom, whose model is small, does show. + // + // So a host with no attachment points seats the effect at the **top centre of its + // own box**, where it marks the object rather than the dirt beneath it. Not the + // box's middle: a node is modelled around its pivot, not above it — the copper + // vein's box is y ∈ [-3.83, +2.73] (measured through this seat's own trace), so + // its centre is 0.55 yd *under* the ground, and the whole mesh above the pivot is + // solid rock that hides anything seated inside it. This is ours: SuperWoW's + // `LootSparkle` has no published source and the reference has no object arm to + // copy (`docs/ADDON_COMPAT.md`). + let rootward = bound.map_or(Vec3::ZERO, |b| { + let (c, h) = (Vec3::from(b.0.center), Vec3::from(b.0.half_extents)); + Vec3::new(c.x, (c.y + h.y).max(0.0), c.z) + }); + let (parent, offset) = point.map_or((unit, rootward), |(_, j, o)| (j, o)); + if benilla_assets::trace::enabled() && point.is_none() { + benilla_assets::trace::line( + "fx", + &format!( + "kit seat unit={unit} offset={offset:?} bound={:?}", + bound.map(|b| (Vec3::from(b.0.center), Vec3::from(b.0.half_extents))) + ), + ); + } let root = commands .spawn((Transform::from_translation(offset), Visibility::default())) // Chain-only, as above. @@ -1067,6 +1101,49 @@ mod tests { ); } + /// A host whose model offers no attachment point at all — every 1.12 GameObject model — seats + /// its instance at the top centre of its own box. Its pivot is the ground point under it, and + /// for a mining node that is inside the rock: the box here is `copper_Miningnode_01.m2`'s own, + /// measured off its 397 vertices, and its *centre* is 0.55 yd underground. + #[test] + fn a_boneless_host_seats_its_instance_at_the_top_of_its_own_box() { + let (mut app, unit, roots) = standing(&[true]); + app.world_mut().entity_mut(roots[0]).despawn(); + app.world_mut().resource_mut::().models.insert( + "Spells\\IceShield_State.mdx".into(), + DisplayModel { + parts: Some(Vec::new()), + ..crate::entities::display::empty_shell() + }, + ); + app.world_mut().entity_mut(unit).insert(( + crate::entities::VisualAttached, + crate::entities::ModelBound(bevy::camera::primitives::Aabb::from_min_max( + Vec3::new(-2.246, -3.833, -2.504), + Vec3::new(1.292, 2.727, 2.059), + )), + )); + app.update(); + app.update(); + let [(_, Some(root))] = instances_of(&app, unit)[..] else { + panic!("the instance spawned: {:?}", instances_of(&app, unit)); + }; + let seat = app + .world() + .entity(root) + .get::() + .expect("the instance root carries its seat") + .translation; + assert!( + (seat.y - 2.727).abs() < 1e-3, + "seated at the box top, not the pivot or the box centre, got {seat:?}" + ); + assert!( + (seat.x - -0.477).abs() < 1e-3 && (seat.z - -0.2225).abs() < 1e-3, + "and over the box's middle, since a model need not straddle its own pivot, got {seat:?}" + ); + } + /// A decaying instance was already ending, so the drain takes it too. #[test] fn a_decaying_instance_whose_root_died_as_collateral_is_dropped() { diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 41aab434..52f020c3 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -414,3 +414,4 @@ with the load checked after each. | SuperWoW's `LootSparkle` sparkled nothing | **fixed** — the decision was right, the switch feeding it was never on | Reported from play: *"Loot sparkle not working on a mining ore I'm looking at"*, with the unit arm visibly working on lootable mobs — which is the shape of the bug, since only the GameObject arm reads the switch. `LootSparkleMode` derived `Default`, so it booted `false` while its row registered `"1"`. A boot fires `CvarChanged` only for rows `config.toml` actually carries, and writing `"1"` over a value already `"1"` is a no-op that fires nothing either, so **nothing could ever tell the resource its real default** — not the boot, not the player toggling it. `vplates.rs`'s `VPlateMode::default` carries that warning in as many words, from `NameplateOcclusion` shipping inert the same way, and this is the same mistake one feature later. Fixed with a hand-written `Default` and the twin test `the_loot_sparkle_default_agrees_with_its_registered_cvar`, which reads the registered row rather than restating it. Swept the rest: of the 32 resources a `CvarChanged` observer writes, 6 derive `Default` (`AssistAttack`, `AuraCastFeed`, `AutoSelfCast`, `BlockTrades`, `FpsJournalSetting`, `GuildMemberNotify`) and **every one of their rows registers `"0"`**, so `LootSparkle` was the only one of this shape | | `PlayerModel:SetUnit` tracked the token instead of holding the unit | **fixed** | unitscan's alert reads `SetUnit'target'` on the rare it just found by name, and the addon restores the player's own target on the next line — so a pane that re-resolved `"target"` per paint showed the card for one frame and then emptied. Reported the moment the by-name targeting stopped leaving the target on the rare: *"unitscan is not targetting anymore, very nice, but now it only shows the 3d model when I do target"*. The reference resolves the token in `SetUnit`'s own rebuild worker (`0x505b50`, the one `RefreshUnit` also runs) and nothing re-resolves it per paint — which is why FrameXML re-issues `SetUnit` from `PaperDollFrame_OnShow` and on `UNIT_MODEL_CHANGED`, and why pfUI can detect a portrait change with **no event at all** by pinning a scratch pane and comparing `GetModel` against the shown one (`api/unitframes.lua:2069`), a test that only says anything if a pane holds what it was given. `ModelState::unit_guid` is that pin, resolved at the call and read by one helper (`Model::unit_pane_subject`) for both the paint gate and the extract. A pane seated **before** its unit streamed in keeps re-reading its token, since our subject arrives asynchronously where the reference's is already in the object manager; `RefreshUnit` re-pins, as its worker does. Test: `a_unit_pane_keeps_the_unit_it_was_given_when_the_token_moves_on`. Still true and still not built: `GetModel()` answers nil on a unit pane here, so pfUI's eventless detection leg can never fire — its event leg does, and that is what draws its portraits | | An item tooltip's rebuild was silent, so an addon's lines lasted one refresh | **fixed** | Reported from play with a video: hovering a bag item draws the enhanced tooltip — MasterTradeSkills' recipe block, AdvancedTradeSkillWindow2's `Bags/Bank/Total` — for about nine frames of sixty, and then the plate reverts to the bare one and stays bare while the cursor sits still. The loop behind it is stock: `ContainerFrameItemButton_OnUpdate` re-enters `..._OnEnter` every `TOOLTIP_UPDATE_TIME` (`ContainerFrame.lua:693` in the player's own `patch-4.mpq`, and pfUI's bag slots are `ContainerFrameItemButtonTemplate`, so they run it too), and each turn is `SetOwner` + `SetBagItem` — a clear that wipes every line an addon appended. What puts them back in the reference is a **hide/show pair**: the `SetOwner` binding hides the plate before it even reads the owner argument (`0x531179` zeroes the content gate at `+0xd0`, `0x531183` calls `[vtable+0x84]` = `0x530a60`, which is the `SetOwner(NULL)` core plus the base hide `0x76ad50`), and the content `Set*` re-shows it through the tooltip's own Show override (`0x530a80`), so every rebuild raises `OnHide` then `OnShow`. Ours cleared in place, and the base show early-outs on an already-visible frame (`0x76ae3b`), so the plate never hid and the `OnShow` never came. `LibItemTooltip-1.0` — the tooltip contract of MasterTradeSkills and Gromissingcrafts — hangs entirely on a scratch child frame of `GameTooltip` listening for `OnShow`, which is why exactly those two addons' lines vanished while pfUI's `ItemID` and sell-price lines, hooked on the `Set*` methods themselves, survived every refresh. All addresses read off the player's own `WoW.exe` | +| The object sparkle was armed but buried inside the object | **fixed** — the placement is ours | *"ore vein is still not sparkling"*, after the switch fix. Reproduced offline in the `fxview` GameObject lane (`WOW_CAPTURE=fxview WOW_FX_GO=310 WOW_FX_GO_TYPE=3`, a copper mining node): the gate fires, `attach_spell_fx` spawns the instance (`kit spawn … Particles\LootFX.mdl`), and `WOW_PARTICLE_CENSUS` lists all four of `LootFX`'s emitters at the node's own position, live and ungated — while the captured frame is **byte-identical** to one taken with the sparkle gated off. The attach cascade's last rung is the host's origin, which for a unit is right (`0x13` is attachment 19, Base, the ground point under its feet, and this is a ground twinkle); a 1.12 **GameObject** model authors no attachment points at all (`copper_Miningnode_01.m2`: 0), so the effect sat at the node's pivot, inside the rock — that model's own vertices run y ∈ [-3.83, +2.73]. A host with no attachment points now seats the effect at the top centre of its own box, and the capture differs; the same sparkle on a peacebloom, whose model is small, already showed at the pivot. The placement is ours and no more: SuperWoW publishes no source and the reference has no object arm to copy. It is also **subtle by design** — `SpellVisualEffectName` row 14 scales 1.0 and `Particles\LootFX.m2` is four emitters of 0.06–0.14 yd particles with ~0.9 yd of reach — so it reads as a few twinkles over the node rather than a beacon |