Files
benilla-octo/crates/benilla-app/src/ui_aura.rs
T
gromandClaude Opus 5 3b8050a91d Party members had no buffs because nothing ever wrote their auras
grom reported it from play: the party frames show no buffs. pfUI's group header
reads `UnitBuff("party1", i)` and the stock `PartyMemberFrame_UpdateAuras` reads
the same verb, and nothing was wrong with the verb — `ui_aura`'s feed pushed six
tokens (player, target, pet, targettarget and ClassicAPI's focus pair) and not
one party token, so `nth_aura` found no list at all for `party1` and every buff
row stopped on the first nil.

The four tokens now get their own lists off `GroupState::party_slots`, through
the same `other_list` builder and the same `buffs_visible_on` gate the target,
pet and focus tokens use — so a party member shows exactly what a targeted party
member shows, and an empty slot clears its token. Each gets its own `UNIT_AURA`
fire keyed on guid and projection, because a party member's auras change on their
own clock and a `"target"` fire tells a party frame nothing.

Not the raid tokens: `raid1`..`raid40` is ten times this work every frame, each
list walking a descriptor and re-running the visibility gate, and that cost has
not been measured. The party's four are bounded and are what was reported.

The guid index and the roster share one system parameter because bevy's
`IntoSystem` tops out at sixteen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-28 21:56:30 -06:00

2442 lines
102 KiB
Rust

//! The aura feed: the `UNIT_FIELD_AURA` blocks of the player, the target, the target's target and
//! the pet as the ordered [`AuraState`] lists the aura bindings read, with their durations: the
//! player's from the wire ([`AuraDurations`]), everyone else's learned from the cast that applied
//! them ([`OtherAuraDurations`]), because 1.12 sends those for the player alone.
//! The player's list keeps the reference cache's insertion order (`0xbc6040`, repacked by
//! `PlayerAuras_Update 0x4e4170`); any other unit's reads its descriptor by ascending slot.
use std::collections::HashMap;
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use benilla_formats::SpellCatalog;
use benilla_protocol::messages::{UnitAuraSlot, AURA_FLAG_CANCELABLE, UNIT_AURA_POSITIVE_SLOTS};
use benilla_ui::script::{AuraState, ScriptValue, TrackingState, UiScript};
use crate::char_select::ClientState;
use crate::net::{
ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, Reputations, SelfPlayer,
};
use crate::target::{can_assist, Factions, Selection};
use crate::ui_action::Spells;
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
/// The player's aura durations by raw slot, each `SMSG_UPDATE_AURA_DURATION` stamped with its
/// arrival: the reference's expiry array `0xbc5f68`. Only the session's end clears it, never an
/// empty slot, whose aura may be a frame behind its packet, nor a worldport. At most 48 entries.
#[derive(Resource, Default)]
pub(crate) struct AuraDurations {
by_slot: HashMap<u8, DurationStamp>,
}
struct DurationStamp {
/// Seconds: the full duration on an apply or refresh, only the remainder on a relog
/// (`Map::ExistingPlayerLogin`) or a cast pushback (`Unit::DelaySpellAuraHolder`).
total: f64,
/// The real-clock instant it runs out: a server-sent span counts down in real seconds.
expires_at: f64,
/// The real-clock arrival, the freshness gate against a recycled slot.
received_at: f64,
}
impl AuraDurations {
/// Records one `SMSG_UPDATE_AURA_DURATION`.
pub(crate) fn set(&mut self, slot: u8, remaining_ms: u32, now: f64) {
let total = f64::from(remaining_ms) / 1000.0;
if trace_period().is_some() {
info!(
"aura trace: SMSG_UPDATE_AURA_DURATION slot {slot} = {remaining_ms} ms @ {now:.2}"
);
}
self.by_slot.insert(
slot,
DurationStamp {
total,
expires_at: now + total,
received_at: now,
},
);
}
}
/// Every other unit's aura durations by `(unit guid, spell id)`, each stamped from the
/// `SMSG_SPELL_GO` that applied it.
///
/// The 1.12 wire carries aura durations for the player alone — `SMSG_UPDATE_AURA_DURATION` is
/// self-only and `SMSG_SET_EXTRA_AURA_INFO`, which carries a caster's durations on *others*, is a
/// 2.0 opcode this protocol does not have. That is why the stock target frame draws no debuff
/// timer and why every vanilla duration addon keeps a table like this one. Kept here instead of
/// in the VM because this client *is* the client: the GO names the spell and its hit list, so the
/// apply instant is the packet's own arrival rather than a combat-log line an addon has to parse,
/// and every reader of the aura lists (`UnitAura`'s Era tuple, `C_UnitAuras`, a nameplate's
/// debuff icons) gets the timer without each inventing its own tracker.
///
/// Keyed by spell, not by slot: a re-cast refreshes the entry in place, and a slot a later aura
/// inherits cannot pick up a stale stamp — so the freshness gate [`join_duration`] needs for the
/// player's by-slot stamps has no counterpart here.
#[derive(Resource, Default)]
pub(crate) struct OtherAuraDurations {
by_aura: HashMap<(u64, u32), DurationStamp>,
}
impl OtherAuraDurations {
/// Records one `SMSG_SPELL_GO`'s aura applications, on every hit that is not the player —
/// whose own durations arrive by packet and are stamped by slot instead.
///
/// The length comes from [`crate::ui_cast_events::aura_duration_ms`], the same reader behind
/// nampower's `AURA_CAST_ON_*` `arg8` and `GetSpellDuration`, so the number an addon is handed
/// and the number our own frames draw cannot disagree. Its `0` — "this client will not say":
/// permanent, no `SpellDuration.dbc` row, or a combo-point/level-scaled one — records nothing
/// at all, leaving the icon timerless rather than counting down to the wrong moment, which is
/// the same trade `docs/ADDON_COMPAT.md` records for `arg8`.
///
/// A hit that turns out to be immune to the aura costs nothing: the join below is driven by
/// the unit's own descriptor, so a stamp no `UNIT_FIELD_AURA` slot ever names is never read.
pub(crate) fn record_spell_go(
&mut self,
hits: &[u64],
spell_id: u32,
self_guid: Option<u64>,
spells: Option<&Spells>,
now: f64,
) {
let Some(spells) = spells else { return };
let Some(display) = spells.catalog.get(spell_id) else {
return;
};
// Only a spell that applies an aura. A pure-damage spell has a `DurationIndex` too (its
// own, for the visual), and stamping it would key a timer nothing can ever read.
if display.effect_apply_aura.iter().all(|&a| a == 0) {
return;
}
let total = f64::from(crate::ui_cast_events::aura_duration_ms(
spells.durations.get(display.duration_index),
)) / 1000.0;
if total <= 0.0 {
return;
}
// Pruned on the packet that would grow the map rather than on every frame of the feed
// that reads it: the live set is the auras applied in the last few seconds, and an idle
// frame should walk nothing.
self.by_aura.retain(|_, d| d.expires_at > now);
for &guid in hits {
if guid == 0 || Some(guid) == self_guid {
continue;
}
self.by_aura.insert(
(guid, spell_id),
DurationStamp {
total,
expires_at: now + total,
received_at: now,
},
);
}
}
/// One aura's `(duration, expirationTime)` on the script clock, `(0, 0)` for an aura no GO of
/// ours applied — or one whose stamp has run out, which is the descriptor sitting a moment
/// behind the server and not a timer worth drawing.
fn join(&self, guid: u64, spell_id: u32, bevy_now: f64, script_now: f64) -> (f64, f64) {
self.by_aura
.get(&(guid, spell_id))
.filter(|d| d.expires_at > bevy_now)
.map(|d| (d.total, script_now + (d.expires_at - bevy_now)))
.unwrap_or((0.0, 0.0))
}
}
/// The join inputs for another unit's aura timers: the table and the two clocks it reads across.
#[derive(Clone, Copy)]
struct AuraClock<'a> {
timers: &'a OtherAuraDurations,
bevy_now: f64,
script_now: f64,
}
/// A record of the player's cache (`0xbc6040`): the position persists, the fields refresh.
struct CachedAura {
slot: u8,
spell_id: u32,
/// When the aura entered the cache, for the duration freshness gate.
appeared_at: f64,
flags: u8,
level: u8,
stacks: u8,
}
/// The player's aura cache: buffs and debuffs interleaved, in insertion order.
#[derive(Resource, Default)]
pub(crate) struct PlayerAuraCache {
auras: Vec<CachedAura>,
}
impl PlayerAuraCache {
/// The live aura spell ids, pre-fed to `ui_tooltip` for a buff-bar hover's `SetPlayerBuff`.
pub(crate) fn spell_ids(&self) -> impl Iterator<Item = u32> + '_ {
self.auras.iter().map(|a| a.spell_id)
}
}
/// The two duration tables the feed joins, as one param: the player's by-slot stamps off the
/// wire and every other unit's by-spell stamps learned from the GO. Read-only — the net path
/// writes both and only the session's end drops them.
#[derive(SystemParam)]
struct AuraStamps<'w> {
player: Res<'w, AuraDurations>,
other: Res<'w, OtherAuraDurations>,
}
/// [`feed_auras`] fires `PLAYER_AURAS_CHANGED` and `UNIT_AURA` inline, so a feed whose state their
/// handlers re-read (the stance feed) runs `.before(AuraEvents)` or they read last frame's.
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct AuraEvents;
/// Adds the aura feed and the `CancelUnitBuff` drain.
pub(crate) struct UiAuraPlugin;
impl Plugin for UiAuraPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AuraDurations>()
.init_resource::<OtherAuraDurations>()
.init_resource::<PlayerAuraCache>()
.init_resource::<AuraFeedMemory>()
// The aura-id edges nampower's `DEBUFF_*_OTHER` pair is a delta on. Declared here as
// well as in `ui_unit.rs`: `App::add_message` is guarded on the resource already
// existing, and a feed that reads a message owes its own registration rather than
// relying on plugin order.
.add_message::<crate::net::FieldChanged>()
// Feed before the VM dispatch, drain after it.
// **Before the dispatch**, so the aura lists are in the VM when `feed_units` fires
// `PLAYER_ENTERING_WORLD`: an addon that reads the whole block in that handler and
// then tracks it by edges alone (DoiteAuras) would otherwise seed from an empty VM and
// stay wrong until something changed. Two systems in one set are ambiguous without
// this, which is why it held on a fresh login and broke after a `/reload`.
.add_systems(
Update,
feed_auras
.in_set(UnitFeed)
.in_set(AuraEvents)
.before(crate::ui_unit::UnitFeedDispatch),
)
.add_systems(Update, drain_aura_cancels.after(UiInput))
// Torn down only at the session's end, not with the avatar, which a worldport drops.
.add_systems(OnExit(ClientState::InWorld), end_session_aura_state);
}
}
/// `PlayerAuras_Update`'s passes: drop a record whose slot lost its spell, closing the gap
/// (`0x4e421b`), then append new slots in ascending order (`0x4e424e`-`0x4e4374`).
fn reconcile(cache: &mut Vec<CachedAura>, live: &[UnitAuraSlot], now: f64) {
cache.retain(|c| {
live.iter()
.any(|a| a.slot == c.slot && a.spell_id == c.spell_id)
});
for a in live {
if let Some(c) = cache.iter_mut().find(|c| c.slot == a.slot) {
c.flags = a.flags;
c.level = a.level;
c.stacks = a.stacks;
} else {
if trace_period().is_some() {
info!(
"aura trace: slot {} took spell {} (descriptor delta) @ {now:.2}",
a.slot, a.spell_id
);
}
cache.push(CachedAura {
slot: a.slot,
spell_id: a.spell_id,
appeared_at: now,
flags: a.flags,
level: a.level,
stacks: a.stacks,
});
}
}
}
/// What a frame repaints on; not the countdown, which the button polls.
type AuraProjection = (u32, u8, bool, Option<String>);
/// The feed's edge memory: per token, the projection last pushed. Behind a
/// [`crate::ui_script::VmMemo`], so a `/reload`'s new VM reads it fresh and `UNIT_AURA` re-fires.
#[derive(Resource, Default)]
struct AuraFeedMemory {
/// What this VM was last told.
vm: crate::ui_script::VmMemo<AuraFeedMemo>,
/// When [`trace_timers`] last printed: host state, outliving any one VM.
traced_at: f64,
/// Per streamed unit, its occupied debuff slots as of the last edge reported — the state
/// nampower's `DEBUFF_*_OTHER` pair is a delta against.
///
/// **Not behind the [`VmMemo`]**, unlike every other key here, and that is the point: these
/// are packet edges, not a repaint. A `/reload` re-applies nobody's debuffs, so telling the
/// new VM that every live debuff was just added would be a claim about an edge that did not
/// happen — where re-firing `UNIT_AURA` is exactly right, because the new frames do need
/// painting.
debuffs: bevy::ecs::entity::EntityHashMap<Vec<DebuffSlot>>,
/// Rebuilt in place per edge, so a steady-state aura change allocates nothing.
debuff_scratch: Vec<DebuffSlot>,
/// The player's own **helpful** half, kept separately because the halves fire different
/// events and so must be diffed apart. The harmful half shares [`Self::debuffs`] with every
/// other unit — one map, keyed by entity, and the player is simply no longer skipped.
own_buffs: Vec<DebuffSlot>,
/// Whether [`Self::own_buffs`] has been seeded. A first sight fires nothing, the same rule
/// the other units' map states by having no entry yet.
own_buffs_seeded: bool,
}
/// The per-VM `UNIT_AURA` edge keys.
#[derive(Default)]
struct AuraFeedMemo {
present: bool,
last: Vec<AuraProjection>,
/// The tracking spell: a tracking switch changes no list, but the minimap needs the event.
tracking_last: Option<u32>,
/// The target's guid and projection: the guid makes a switch between equal lists re-fire.
/// The pet's and the target's target's are keyed the same way.
target_last: Option<(u64, Vec<AuraProjection>)>,
pet_last: Option<(u64, Vec<AuraProjection>)>,
tot_last: Option<(u64, Vec<AuraProjection>)>,
/// ClassicAPI's focus pair (`docs/ADDON_COMPAT.md`), keyed like the three above.
focus_last: Option<(u64, Vec<AuraProjection>)>,
focus_target_last: Option<(u64, Vec<AuraProjection>)>,
/// `party1`..`party4`, keyed like the rest: the guid makes a roster reshuffle re-fire even
/// where two members carry the same auras.
party_last: [Option<(u64, Vec<AuraProjection>)>; 4],
}
// ─── nampower v2's DEBUFF_ADDED_OTHER / DEBUFF_REMOVED_OTHER ──────────────────────────────────
/// One occupied debuff slot, the whole payload nampower's buff/debuff events carry about one
/// aura: upstream `EVENTS.md` gives all eight of them the same seven arguments —
/// `string guid, int luaSlot, int spellId, int stackCount, int auraLevel, int auraSlot, int state`
/// — and every column but `guid`, `luaSlot` and `state` is a field of this record.
///
/// `UNIT_FIELD_AURA` is the only source: slot, spell id, stack count and the caster's level at
/// apply time all ride the descriptor benilla already parses ([`UnitAuraSlot`]). There is nothing
/// inferred here, which is why this pair can be fired at all.
#[derive(Clone, Copy, PartialEq, Debug)]
struct DebuffSlot {
/// `auraSlot`: the raw slot, which upstream reports **0-based** (32..47 for the debuff half).
/// pfUI converts back (`libdebuff.lua:1350`, `arg6 + 1`), so the raw byte goes out as-is.
slot: u8,
spell_id: u32,
stacks: u8,
/// `auraLevel`, upstream's "caster level for the aura from UnitFields.auraLevels" — the wire's
/// `UNIT_FIELD_AURALEVELS` byte, and the only trace of the caster the 1.12 descriptor keeps.
level: u8,
}
/// The watch span these two events need: the 48 `UNIT_FIELD_AURA` spell-id dwords and nothing
/// else.
///
/// Narrower than `creature_anim::spell_visual`'s 54 (ids plus the packed flag words) on purpose.
/// A slot gaining or losing a *spell* is exactly the add/remove edge this pair reports; a stack
/// or flag move within a slot that keeps its spell is a refresh, and upstream fires neither event
/// for one — pfUI says so from the consumer side ("DEBUFF_ADDED doesn't fire on refresh, so we
/// must update here!", `libdebuff.lua:1427`, where it patches the gap off `AURA_CAST` instead).
///
/// One consequence, stated rather than left to be found: a stack count that moves while the ids
/// stand still does not wake this, so a later removal can report the count as of the last id edge.
/// `arg4` on a removal is read by nothing in the corpus, and widening the span to
/// `UNIT_FIELD_AURAAPPLICATIONS` would trade that for waking on every stack tick of every
/// stacking debuff in streaming range — the per-frame cost this watch exists to avoid.
const AURA_ID_BASE: u16 = benilla_protocol::field::FIELD_UNIT_AURA;
const AURA_ID_SPAN: u16 = benilla_protocol::messages::UNIT_AURA_SLOTS as u16;
/// `luaSlot` (`arg2`) for the aura in `slot`: upstream's "1-based Lua slot index for the
/// buff/debuff (skips empty/hidden slots to match UnitBuff/UnitDebuff ordering). If the aura that
/// triggered the event itself is hidden, this value is `0`."
///
/// Counted over `list` — which the caller passes as the state the aura *was in*: the current one
/// for an add, the previous one for a removal, since a removed aura has no index in the list it
/// just left. [`shown_in_aura_ui`] is the same hidden filter `other_unit_auras` and the player's
/// own rebuild use, so the index agrees with what `UnitDebuff` would return.
fn debuff_lua_slot(catalog: Option<&SpellCatalog>, list: &[DebuffSlot], slot: u8) -> i64 {
let Some(me) = list.iter().find(|d| d.slot == slot) else {
return 0;
};
if !shown_in_aura_ui(catalog, me.spell_id) {
return 0;
}
// `list` is in ascending slot order (`ObjectFields::unit_auras`), so this is a position.
1 + list
.iter()
.filter(|d| d.slot < slot && shown_in_aura_ui(catalog, d.spell_id))
.count() as i64
}
/// One unit's debuff-slot delta as nampower's two `*_OTHER` events.
///
/// **Removals before adds**, which is load-bearing rather than tidy: one descriptor delta can
/// recycle a slot (an aura expires and another takes its index in the same update), and pfUI keys
/// its ownership table on the raw slot and clears that key on removal
/// (`libdebuff.lua:1507-1512`). Adds first, and the removal would wipe the entry the add just
/// wrote.
///
/// `state` (`arg7`) is `0` for an add and `1` for a removal, upstream's own legend ("0 = newly
/// added, 1 = newly removed, 2 = modified (stack change)"). `2` is never sent here: a stack-only
/// move is not an edge this watch sees (see [`AURA_ID_SPAN`]), nothing in the corpus reads `arg7`
/// at all, and the one reading under which a stack *decrease* would fire the REMOVED event has
/// pfUI clearing a live debuff's slot ownership and its timer — a wrong timer, which is the
/// failure this whole family exists to avoid.
fn fire_debuff_edges(
script: &mut UiScript,
guid: u64,
catalog: Option<&SpellCatalog>,
prev: &[DebuffSlot],
cur: &[DebuffSlot],
kind: AuraEdge,
) {
let same = |a: &DebuffSlot, b: &DebuffSlot| a.slot == b.slot && a.spell_id == b.spell_id;
let args = |d: &DebuffSlot, lua_slot: i64, state: i64| {
vec![
ScriptValue::Str(benilla_ui::script::guid_token(guid)),
ScriptValue::Int(lua_slot),
ScriptValue::Int(i64::from(d.spell_id)),
ScriptValue::Int(i64::from(d.stacks)),
ScriptValue::Int(i64::from(d.level)),
ScriptValue::Int(i64::from(d.slot)),
ScriptValue::Int(state),
]
};
// The names are written out as LITERALS at the call, so `reference_ui.rs`'s beyond-1.12 event
// gate can see them: its walker reads the string argument here and cannot follow a name
// through a variable. Routing them through one `&str` parameter compiled fine and left the
// gate blind — including to the two `*_OTHER` names it had already been told about.
for p in prev {
if !cur.iter().any(|c| same(c, p)) {
let a = args(p, debuff_lua_slot(catalog, prev, p.slot), 1);
match kind {
AuraEdge::OwnBuff => script.fire_event("BUFF_REMOVED_SELF", a),
AuraEdge::OwnDebuff => script.fire_event("DEBUFF_REMOVED_SELF", a),
AuraEdge::OtherDebuff => script.fire_event("DEBUFF_REMOVED_OTHER", a),
}
}
}
for c in cur {
if !prev.iter().any(|p| same(p, c)) {
let a = args(c, debuff_lua_slot(catalog, cur, c.slot), 0);
match kind {
AuraEdge::OwnBuff => script.fire_event("BUFF_ADDED_SELF", a),
AuraEdge::OwnDebuff => script.fire_event("DEBUFF_ADDED_SELF", a),
AuraEdge::OtherDebuff => script.fire_event("DEBUFF_ADDED_OTHER", a),
}
}
}
}
/// Which of nampower's aura-edge families one diff reports.
///
/// Upstream `EVENTS.md` has **eight** — `BUFF_`/`DEBUFF_` by half, `_SELF`/`_OTHER` by whether the
/// unit is the player. Three are produced here, and the missing pair is named rather than left to
/// be noticed: `BUFF_ADDED_OTHER`/`BUFF_REMOVED_OTHER` would mean diffing the helpful half of
/// **every streamed unit** on every aura edge, and no addon in this corpus registers either. A
/// variant for them would be a dead arm firing events the gate then has to allow, which is worse
/// than their absence. Add them the day something reads them.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum AuraEdge {
/// The player's helpful half: `BUFF_ADDED_SELF` / `BUFF_REMOVED_SELF`.
OwnBuff,
/// The player's harmful half: `DEBUFF_ADDED_SELF` / `DEBUFF_REMOVED_SELF`.
OwnDebuff,
/// Any other unit's harmful half: `DEBUFF_ADDED_OTHER` / `DEBUFF_REMOVED_OTHER`.
OtherDebuff,
}
/// One unit's occupied debuff slots into `out`, reusing its allocation: the negative half of
/// `UNIT_FIELD_AURA`, ascending, hidden auras included.
///
/// Hidden auras are kept deliberately — they take a *slot* even though `UnitDebuff` skips them,
/// so dropping them here would shift every `luaSlot` below one, and upstream explicitly reports
/// an edge on a hidden aura (with `luaSlot` 0) rather than suppressing it.
fn collect_debuff_slots(store: &ObjectStore, out: &mut Vec<DebuffSlot>) {
collect_aura_slots(store, out, true);
}
/// One unit's occupied slots from one half of `UNIT_FIELD_AURA`: the harmful half (32..47) when
/// `harmful`, the helpful half (0..31) otherwise. Two calls cover the block, and each half is
/// diffed on its own because the event names differ by half, not by unit.
fn collect_aura_slots(store: &ObjectStore, out: &mut Vec<DebuffSlot>, harmful: bool) {
out.clear();
out.extend(
store
.0
.unit_auras()
.filter(|a| (a.slot >= UNIT_AURA_POSITIVE_SLOTS) == harmful)
.map(|a| DebuffSlot {
slot: a.slot,
spell_id: a.spell_id,
stacks: a.stacks,
level: a.level,
}),
);
}
/// The `BENILLA_AURA_TRACE` period in seconds; a set value that is not a positive number means 1 s.
fn trace_period() -> Option<f64> {
static PERIOD: std::sync::OnceLock<Option<f64>> = std::sync::OnceLock::new();
*PERIOD.get_or_init(|| {
let v = std::env::var("BENILLA_AURA_TRACE").ok()?;
Some(
v.trim()
.parse::<f64>()
.ok()
.filter(|p| *p > 0.0)
.unwrap_or(1.0),
)
})
}
/// One trace tick: each drawn aura's time left beside its button's text, the button polling
/// `GetPlayerBuffTimeLeft` every frame (`BuffFrame.lua:130`). Buffs fill `BuffButton0..15`,
/// debuffs `BuffButton16..23`.
fn trace_timers(script: &UiScript, cache: &[CachedAura], list: &[AuraState], bevy_now: f64) {
let script_now = script.now();
info!(
"aura trace: {} aura(s), GetTime()={script_now:.2} app clock={bevy_now:.2} (skew {:+.2})",
list.len(),
script_now - bevy_now
);
let (mut helpful_n, mut harmful_n) = (0usize, 0usize);
for (c, a) in cache.iter().zip(list) {
let button = if a.helpful {
helpful_n += 1;
helpful_n - 1
} else {
harmful_n += 1;
15 + harmful_n
};
let app_left = if a.expiration_time > 0.0 {
format!("{:.1}s", a.expiration_time - script_now)
} else {
"permanent".to_string()
};
// The rendered duration text: the button keeps no expiry, so its text is the readback.
let lua = script
.eval::<String>(&format!(
r#"local b = getglobal("BuffButton{button}")
local d = getglobal("BuffButton{button}Duration")
if not b then return "no such button" end
return string.format("shown=%s draws %q",
tostring(b:IsShown()), d and d:GetText() or "")"#
))
.unwrap_or_else(|e| format!("<lua error: {e}>"));
info!(
" slot {:>2} spell {:>5} {:<26} BuffButton{button:<2} app left {app_left:<10} lua {lua}",
c.slot,
a.spell_id,
a.name.as_deref().unwrap_or("<unknown spell>"),
);
}
}
fn projection_of(list: &[AuraState]) -> Vec<AuraProjection> {
list.iter()
.map(|a| (a.spell_id, a.count, a.cancelable, a.debuff_type.clone()))
.collect()
}
/// Another unit's auras as `UnitBuff 0x519500` and `UnitDebuff 0x5198f0` read them: ascending
/// slot within each half, through the display filter. `buffs_visible` false drops the helpful
/// half, the unit-level gate's nil at every index ([`buffs_visible_on`]).
///
/// Durations come from [`OtherAuraDurations`], joined by `guid` and spell id. **Deviation:** the
/// reference has none here at all, because the wire sends it none; these are learned from the GO
/// that applied the aura, which is what the mods this client stands in for do.
fn other_unit_auras(
store: &ObjectStore,
guid: u64,
catalog: Option<&SpellCatalog>,
buffs_visible: bool,
clock: AuraClock,
) -> Vec<AuraState> {
store
.0
.unit_auras()
.filter(|a| buffs_visible || a.slot >= UNIT_AURA_POSITIVE_SLOTS)
.filter(|a| shown_in_aura_ui(catalog, a.spell_id))
.map(|a| {
let display = catalog.and_then(|cat| cat.get(a.spell_id));
let (duration, expiration_time) =
clock
.timers
.join(guid, a.spell_id, clock.bevy_now, clock.script_now);
AuraState {
spell_id: a.spell_id,
name: display.map(|d| d.name.clone()),
icon: display.and_then(|d| d.icon.clone()),
count: a.stacks,
debuff_type: display
.zip(catalog)
.and_then(|(d, cat)| cat.dispel_name(d))
.map(str::to_string),
duration,
expiration_time,
helpful: a.slot < UNIT_AURA_POSITIVE_SLOTS,
cancelable: a.flags & AURA_FLAG_CANCELABLE != 0,
// `untilCancelled` is the player cache record's alone (`0xbc6040` `+0xc`).
until_cancelled: false,
channeled: display
.is_some_and(|d| d.attributes_ex & SPELL_ATTR_EX_IS_CHANNELED != 0),
// The raw wire slot, for nampower's `GetUnitField`; every other reader indexes the
// compacted list this builds.
slot: a.slot,
}
})
.collect()
}
/// `UnitBuff 0x519500`'s unit-level gate, before any slot: a unit's buffs are listed only if it
/// has `UNIT_FLAG_AURAS_VISIBLE` or the uncharmed player can assist it (`0x5195aa`-`0x5195f7`),
/// else every index is nil; `UnitDebuff` has no gate. vmangos sets the flag for Detect Magic
/// (`UnitDefines.h:572`) and for a GM viewer (`Object.cpp:765-766`), so it is read as received.
fn buffs_visible_on(
store: &ObjectStore,
self_store: Option<&ObjectStore>,
factions: Option<&Factions>,
reputations: &Reputations,
owner_store: impl FnOnce(u64) -> Option<ObjectStore>,
) -> bool {
if store.0.unit_flags() & UNIT_FLAG_AURAS_VISIBLE != 0 {
return true;
}
// While charmed you assist nobody (`0x5195f1`-`0x5195f7`).
if self_store.is_some_and(|s| s.0.unit_charmed_by().is_some()) {
return false;
}
can_assist(Some(store), factions, reputations, self_store, owner_store)
}
/// `UNIT_FIELD_FLAGS` bit 27, `0x08000000` (`0x5195b6`).
const UNIT_FLAG_AURAS_VISIBLE: u32 = 0x0800_0000;
/// `AttributesEx & 0x4` (vmangos `SpellDefines.h:834`), the cancel gate's DBC arm (`0x4e4a10`).
const SPELL_ATTR_EX_IS_CHANNELED: u32 = 0x4;
/// `GetPlayerBuff`'s second return, cache record `+0xc`, set by `BuildBuffRecord 0x4e44b0` when
/// `DurationIndex` names an infinite `SpellDuration.dbc` row, base < 0 and per level <= 0
/// (`0x4e456e`-`0x4e457a`), or no row (`0x4e4580`); or when the aura is not cancelable and an
/// effect is an area aura, `0x23`, `0x77`, `0x80` or `0x81` (`0x4e4588`-`0x4e45be`).
/// `BuffFrame.lua:124` then shows no countdown. Deviation: on a catalog miss the reference keeps
/// the record's stale value (`0x4e4533`, `0x4e4550` skip the write); this answers from the joined
/// expiry, because a stale value is no mechanism to reproduce.
fn until_cancelled(
display: Option<&benilla_formats::SpellDisplay>,
duration_row: Option<&benilla_formats::SpellDuration>,
cancelable: bool,
expiration_time: f64,
) -> bool {
let Some(d) = display else {
return expiration_time == 0.0;
};
let permanent = match duration_row {
Some(row) => row.base_ms < 0 && row.per_level_ms <= 0,
// A negative, out-of-range or empty `DurationIndex`: all three reach `0x4e4580`.
None => true,
};
permanent
|| (!cancelable
&& d.effects
.iter()
.any(|e| matches!(e, 0x23 | 0x77 | 0x80 | 0x81)))
}
/// The filter of every aura display, the player's bar and other units' rows alike: never-display
/// and tracking spells are hidden. The player's rebuild tests the attributes
/// (`0x4e42b6`-`0x4e42c8`), then diverts a tracking aura to the tracking global instead of the
/// cache (`0x4e42d6`-`0x4e4308`); `IsAuraDisplayable 0x519860` hides both from other units' rows.
/// Deviation: on other units' rows an id the catalog lacks stays visible, because every wire id is
/// a real spell and failing closed would blank every aura on a catalog failure; the reference
/// skips one there (`[0xc0d78c]`), though its player cache inserts one, as here.
fn shown_in_aura_ui(catalog: Option<&SpellCatalog>, spell_id: u32) -> bool {
catalog
.and_then(|c| c.get(spell_id))
.is_none_or(|d| !d.hidden_from_aura_bar() && !d.tracking_aura())
}
/// The player's tracking aura, the reference's global `0xbc6378`: the rebuild's ascending walk
/// overwrites it per tracking aura, so the last wins (`0x4e4302`), after the attribute test; a
/// catalog miss cannot be told apart and goes to the bar. Read by `GetTrackingTexture`,
/// `CancelTrackingBuff` and `GameTooltip:SetTrackingSpell` ([`UiScript::set_tracking`]).
fn tracking_state_of(
catalog: Option<&SpellCatalog>,
occupied: &[UnitAuraSlot],
) -> Option<TrackingState> {
occupied.iter().rev().find_map(|a| {
let d = catalog.and_then(|c| c.get(a.spell_id))?;
(!d.hidden_from_aura_bar() && d.tracking_aura()).then(|| TrackingState {
spell_id: a.spell_id,
name: Some(d.name.clone()),
icon: d.icon.clone(),
cancelable: a.flags & AURA_FLAG_CANCELABLE != 0,
})
})
}
fn feed_auras(
script: Option<NonSendMut<UiScript>>,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
selection: Res<Selection>,
stores: Query<&ObjectStore>,
spells: Option<Res<Spells>>,
pet: Res<crate::ui_pet::PetBar>,
// The guid index and the party roster, one parameter because bevy's `IntoSystem` tops out at
// sixteen: both are "who is who" lookups this body only reads.
(index, group): (Res<GuidIndex>, Res<crate::ui_party::GroupState>),
// The `UnitBuff` gate's inputs, the same reaction the selection ring and nameplates use.
factions: Option<Res<Factions>>,
reputations: Res<Reputations>,
stamps: AuraStamps,
mut cache: ResMut<PlayerAuraCache>,
time: Res<Time<Real>>,
mut mem: ResMut<AuraFeedMemory>,
// nampower v2's `DEBUFF_*_OTHER` pair (`docs/ADDON_COMPAT.md`). Three read-only additions to
// this feed rather than a system of its own: the events are a delta on the same descriptor
// this body already reads, and a second VM-holding system would be a second place the aura
// state reaches Lua from, which the feed's own header is written against.
mut aura_edges: MessageReader<crate::net::FieldChanged>,
arrived: Query<Entity, (Added<ObjectStore>, Without<crate::items::ItemObject>)>,
watched: Query<(&ObjectStore, &Guid), Without<crate::items::ItemObject>>,
) {
let Some(mut script) = script else {
return;
};
let Ok((store, self_guid)) = self_q.single() else {
// No avatar does not end the aura state: a worldport re-streams the avatar mid-session, and
// vmangos re-sends durations only on apply, refresh and `Map::ExistingPlayerLogin`. The
// reference's `0xbc5f68` is zeroed once, by the startup init (`0x4e40c0` from `0x48f5a9`),
// and touched otherwise only by its setter `0x4e43a6` and reader `0x4e4467`.
return;
};
let bevy_now = time.elapsed_secs_f64();
let catalog = spells.as_ref().map(|s| &s.catalog);
let spell_durations = spells.as_ref().map(|s| &s.durations);
let occupied: Vec<UnitAuraSlot> = store.0.unit_auras().collect();
// Filtered before the cache, so a hidden aura takes no position (`0x4e42b6`-`0x4e4308`).
let live: Vec<UnitAuraSlot> = occupied
.iter()
.filter(|a| shown_in_aura_ui(catalog, a.spell_id))
.copied()
.collect();
reconcile(&mut cache.auras, &live, bevy_now);
// No stamp is pruned for an empty slot: the packet arrives before its slot fills.
let script_now = script.now();
let list: Vec<AuraState> = cache
.auras
.iter()
.map(|c| {
let display = catalog.and_then(|cat| cat.get(c.spell_id));
let (duration, expiration_time) = join_duration(
stamps.player.by_slot.get(&c.slot),
c.appeared_at,
bevy_now,
script_now,
);
AuraState {
spell_id: c.spell_id,
name: display.map(|d| d.name.clone()),
icon: display.and_then(|d| d.icon.clone()),
count: c.stacks,
debuff_type: display
.zip(catalog)
.and_then(|(d, cat)| cat.dispel_name(d))
.map(str::to_string),
duration,
expiration_time,
helpful: c.slot < UNIT_AURA_POSITIVE_SLOTS,
cancelable: c.flags & AURA_FLAG_CANCELABLE != 0,
until_cancelled: until_cancelled(
display,
display.and_then(|d| spell_durations.and_then(|c| c.get(d.duration_index))),
c.flags & AURA_FLAG_CANCELABLE != 0,
expiration_time,
),
channeled: display
.is_some_and(|d| d.attributes_ex & SPELL_ATTR_EX_IS_CHANNELED != 0),
// The player cache keeps the wire slot it was built from, so `GetUnitField`
// answers for `"player"` exactly as it does for any other unit.
slot: c.slot,
}
})
.collect();
// Every other unit's timers ride on one clock pair, converted at the join: the stamps are
// kept on the app's real clock and `expirationTime` is read on `GetTime()`'s.
let clock = AuraClock {
timers: &stamps.other,
bevy_now,
script_now,
};
// Before the memory update, so a tracking change joins the edge key.
let tracking = tracking_state_of(catalog, &occupied);
let tracking_spell = tracking.as_ref().map(|t| t.spell_id);
if let Some(period) = trace_period() {
if bevy_now - mem.traced_at >= period {
mem.traced_at = bevy_now;
trace_timers(&script, &cache.auras, &list, bevy_now);
}
}
// This VM's keys: a `/reload`'s new VM starts empty, so every edge re-fires.
let memo = mem.vm.get(&script);
// Fire on a discrete change, never on the countdown; a tracking switch counts.
let projection = projection_of(&list);
let changed = !memo.present || projection != memo.last || tracking_spell != memo.tracking_last;
memo.last = projection;
memo.tracking_last = tracking_spell;
memo.present = true;
if changed && std::env::var_os("BENILLA_AURA_DUMP").is_some() {
info!("aura dump: {} aura(s) on the player bar", cache.auras.len());
for c in &cache.auras {
let name = catalog
.and_then(|cat| cat.get(c.spell_id))
.map(|d| d.name.as_str())
.unwrap_or("<unknown spell>");
info!(
" slot {:>2} spell {:>5} {:<26} {:<6} flags {:#06b}",
c.slot,
c.spell_id,
name,
if c.slot < UNIT_AURA_POSITIVE_SLOTS {
"buff"
} else {
"debuff"
},
c.flags,
);
}
}
// The target's rows. A self-target mirrors the player list in cache order, where the
// reference's `UnitBuff` reads by slot.
let target_list: Option<Vec<AuraState>> =
selection.target.zip(selection.guid).and_then(|(e, guid)| {
if guid == self_guid.0 {
return Some(list.clone());
}
// Not `store`: that is the player's, the gate's second argument.
let target_store = stores.get(e).ok()?;
let buffs = buffs_visible_on(
target_store,
Some(store),
factions.as_deref(),
&reputations,
|owner| {
index
.0
.get(&owner)
.and_then(|&e| stores.get(e).ok())
.cloned()
},
);
Some(other_unit_auras(target_store, guid, catalog, buffs, clock))
});
// The pet's list: the stock pet frame's four buttons draw its buffs (`PetFrame.lua:37,56`).
let pet_guid = pet.spells.pet_guid;
let pet_list: Option<Vec<AuraState>> = (pet_guid != 0)
.then(|| index.0.get(&pet_guid))
.flatten()
.and_then(|&e| stores.get(e).ok())
// Ungated; the reference gates a pet too, but `CanAssist`'s player-controlled arm
// (`0x60673e`-`0x60679f`, the controlling players' duel and FFA-PvP state) passes for
// your own pet, leaving only the charm, selectable and reaction clauses to hide its buffs.
.map(|store| other_unit_auras(store, pet_guid, catalog, true, clock));
// One streamed unit's visible rows by guid, the shape every token below the target reads:
// the `UnitBuff` gate against our own descriptor, then the cache-order list. A guid that is
// ours mirrors the player list, as a self-target does.
let other_list = |guid: u64| -> Option<Vec<AuraState>> {
if guid == self_guid.0 {
return Some(list.clone());
}
// Not `store`: that is the player's, the gate's second argument.
let unit_store = stores.get(*index.0.get(&guid)?).ok()?;
let buffs = buffs_visible_on(
unit_store,
Some(store),
factions.as_deref(),
&reputations,
|owner| {
index
.0
.get(&owner)
.and_then(|&e| stores.get(e).ok())
.cloned()
},
);
Some(other_unit_auras(unit_store, guid, catalog, buffs, clock))
};
// A unit's own `UNIT_FIELD_TARGET`, streamed guids only; 0 is "no target of that unit".
let target_of = |guid: u64| -> u64 {
index
.0
.get(&guid)
.and_then(|&e| stores.get(e).ok())
.and_then(|s| s.0.unit_target())
.unwrap_or(0)
};
// The target's target's list, for the ToT frame's four debuff buttons, through the one-hop
// `UNIT_FIELD_TARGET` read.
let tot_guid = selection
.target
.and_then(|e| stores.get(e).ok())
.and_then(|s| s.0.unit_target())
.filter(|g| *g != 0)
.unwrap_or(0);
let tot_list: Option<Vec<AuraState>> = (tot_guid != 0).then(|| other_list(tot_guid)).flatten();
// ClassicAPI's `"focus"`/`"focustarget"` (`docs/ADDON_COMPAT.md`): the same two hops off the
// VM's stored focus guid. pfUI's focus frames draw buffs and debuffs from these and hear only
// `UNIT_AURA` with their own token, so the lists and the event both have to be per-token —
// the `"target"` fire tells a focus frame nothing.
let focus_guid = script.focus_guid().unwrap_or(0);
let focus_list: Option<Vec<AuraState>> =
(focus_guid != 0).then(|| other_list(focus_guid)).flatten();
let focus_target_guid = if focus_guid != 0 {
target_of(focus_guid)
} else {
0
};
let focus_target_list: Option<Vec<AuraState>> = (focus_target_guid != 0)
.then(|| other_list(focus_target_guid))
.flatten();
let target_cur = selection
.guid
.zip(target_list.as_deref())
.map(|(guid, l)| (guid, projection_of(l)));
let target_changed = target_cur.is_some() && target_cur != memo.target_last;
memo.target_last = target_cur;
if target_changed && std::env::var_os("BENILLA_AURA_DUMP").is_some() {
let l = target_list.as_deref().unwrap_or_default();
info!("aura dump: {} aura(s) on the target rows", l.len());
for a in l {
info!(
" spell {:>5} {:<26} {:<6} {}",
a.spell_id,
a.name.as_deref().unwrap_or("<unknown spell>"),
if a.helpful { "buff" } else { "debuff" },
a.debuff_type.as_deref().unwrap_or("-"),
);
}
}
let pet_cur = (pet_guid != 0)
.then_some(pet_list.as_deref())
.flatten()
.map(|l| (pet_guid, projection_of(l)));
let pet_changed = pet_cur.is_some() && pet_cur != memo.pet_last;
memo.pet_last = pet_cur;
let tot_cur = (tot_guid != 0)
.then_some(tot_list.as_deref())
.flatten()
.map(|l| (tot_guid, projection_of(l)));
let tot_changed = tot_cur.is_some() && tot_cur != memo.tot_last;
memo.tot_last = tot_cur;
let focus_cur = (focus_guid != 0)
.then_some(focus_list.as_deref())
.flatten()
.map(|l| (focus_guid, projection_of(l)));
let focus_changed = focus_cur.is_some() && focus_cur != memo.focus_last;
memo.focus_last = focus_cur;
let focus_target_cur = (focus_target_guid != 0)
.then_some(focus_target_list.as_deref())
.flatten()
.map(|l| (focus_target_guid, projection_of(l)));
let focus_target_changed =
focus_target_cur.is_some() && focus_target_cur != memo.focus_target_last;
memo.focus_target_last = focus_target_cur;
// `party1`..`party4`. **The absence of these was a defect grom reported from play**: no buffs
// on party members in the party frames, because pfUI's group header reads
// `UnitBuff("party1", i)` (`api/unitframes.lua`) and this feed pushed six tokens, none of them
// a party one, so `nth_aura` found no list at all and every party buff row stayed empty. The
// stock party frames show buffs too (`PartyMemberFrame_UpdateAuras`), so nothing about this is
// pfUI's.
//
// Nothing new is computed per unit: `other_list` is the same builder the target, pet and focus
// tokens go through, with the same `buffs_visible_on` gate, so a party member shows exactly
// what a targeted party member shows. Four slots in roster order
// (`GroupState::party_slots`), which is the order the tokens themselves mean; an empty slot
// clears its token.
//
// **Not the raid tokens.** `raid1`..`raid40` would be ten times this work every frame — each
// list walks the unit's descriptor and re-runs the visibility gate — and the cost has not been
// measured, where the party's four are bounded and are what was reported. Left as its own
// piece of work with that measurement first; `ui_party::RAID_TOKENS` is the list.
let party_guids: Vec<u64> = group.party_slots().map(|m| m.guid).collect();
let party_lists: Vec<Option<Vec<AuraState>>> = crate::ui_party::PARTY_TOKENS
.iter()
.enumerate()
.map(|(i, _)| {
party_guids
.get(i)
.copied()
.filter(|g| *g != 0)
.and_then(other_list)
})
.collect();
script.set_auras("player", Some(list));
// Clearing a token fires nothing: the frames react to `PLAYER_TARGET_CHANGED` and `UNIT_PET`,
// and the focus pair to `PLAYER_FOCUS_CHANGED`.
script.set_auras("target", target_list);
script.set_auras("pet", pet_list);
script.set_auras("targettarget", tot_list);
script.set_auras("focus", focus_list);
script.set_auras("focustarget", focus_target_list);
// The four party tokens, with their own edge keys: a party member's auras change on their
// own clock, so a `"target"` fire tells a party frame nothing.
let party_changed: Vec<bool> = party_lists
.iter()
.enumerate()
.map(|(i, list)| {
let cur = party_guids
.get(i)
.copied()
.filter(|g| *g != 0)
.zip(list.as_deref())
.map(|(guid, l)| (guid, projection_of(l)));
let moved = cur != mem.vm.get(&script).party_last[i];
mem.vm.get(&script).party_last[i] = cur;
moved
})
.collect();
for (token, list) in crate::ui_party::PARTY_TOKENS.iter().zip(party_lists) {
script.set_auras(token, list);
}
script.set_tracking(tracking);
if changed {
script.fire_event("UNIT_AURA", vec![ScriptValue::Str("player".into())]);
// The cache rebuild's own event, no arguments (`0x4e437f`): the stock buff bar and
// `MiniMapTrackingFrame` register it, the unit frames `UNIT_AURA`.
script.fire_event("PLAYER_AURAS_CHANGED", vec![]);
}
if pet_changed {
script.fire_event("UNIT_AURA", vec![ScriptValue::Str("pet".into())]);
}
if target_changed {
script.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]);
}
if tot_changed {
script.fire_event("UNIT_AURA", vec![ScriptValue::Str("targettarget".into())]);
}
if focus_changed {
script.fire_event("UNIT_AURA", vec![ScriptValue::Str("focus".into())]);
}
if focus_target_changed {
script.fire_event("UNIT_AURA", vec![ScriptValue::Str("focustarget".into())]);
}
for (token, moved) in crate::ui_party::PARTY_TOKENS.iter().zip(&party_changed) {
if *moved {
script.fire_event("UNIT_AURA", vec![ScriptValue::Str((*token).to_string())]);
}
}
// ── nampower v2's `DEBUFF_ADDED_OTHER` / `DEBUFF_REMOVED_OTHER` ──────────────────────────
//
// After the lists are pushed, for the same reason `drain_cast_edges` pushes its windows before
// its events: pfUI's handler asks `C_UnitAuras` about the unit it was just told about, and the
// two must not disagree inside the handler the event woke.
//
// **Cost**: driven off `FieldChanged`, so a frame in which no unit's aura ids moved does
// nothing here but drain an empty message list. An edge costs one ascending walk of that one
// unit's 48 descriptor slots into a reused buffer, plus a slot-by-slot compare — the same
// shape and the same trigger `creature_anim::spell_visual`'s `arm_aura_state_fx` already pays
// on these exact edges, and no per-frame sweep over streamed units at any point.
//
// `memo`'s borrow of `mem` ends above; these two fields are the host-side half.
let AuraFeedMemory {
debuffs,
debuff_scratch,
own_buffs,
own_buffs_seeded,
..
} = &mut *mem;
// A unit streamed in mid-debuff raises no field edge — its aura ids arrive with the object —
// so first sight **seeds** the memory and fires nothing. Those debuffs were not newly added,
// nothing here watched them land, and the descriptor keeps no caster for them, so reporting
// them would be a burst of events claiming an edge that happened before we were looking (and
// a burst exactly where it hurts, on streaming into a crowd). The seed is what makes the next
// real application an *add* rather than being swallowed as a first-time diff.
for e in &arrived {
if let Ok((store, _guid)) = watched.get(e) {
// The player is seeded here too now that it has events of its own: without this its
// first real edge would be swallowed as a first-time diff, which is one lost
// `BUFF_ADDED_SELF` per login.
if !debuffs.contains_key(&e) {
collect_debuff_slots(store, debuff_scratch);
debuffs.insert(e, debuff_scratch.clone());
}
}
}
let due: bevy::ecs::entity::EntityHashSet = aura_edges
.read()
.filter(|e| e.unit_array_slot(AURA_ID_BASE, AURA_ID_SPAN).is_some())
.map(|e| e.entity)
.collect();
for e in &due {
// The player's own debuffs are `DEBUFF_ADDED_SELF`/`_REMOVED_SELF`, a different pair this
// client does not fire (`docs/ADDON_COMPAT.md`); the `*_OTHER` half is every other unit.
let Ok((store, guid)) = watched.get(*e) else {
continue;
};
let own = guid.0 == self_guid.0;
// The player's HELPFUL half, which only it has events for: `BUFF_ADDED_SELF` and its
// removal. Diffed before the harmful half below so one edge that moves both reports the
// buff first, which is the order a single ascending walk of the block would give.
//
// DoiteAuras is the consumer that made this matter: it reads the whole aura block once at
// `PLAYER_ENTERING_WORLD` and then relies **entirely** on these four events to keep it
// current (`Modules/DoitePlayerAuras.lua:403-490`, `UpdateAuras` called nowhere else). So
// with none of them fired its table froze at login — every buff gained afterwards read as
// missing for the rest of the session, which is exactly the "always showing in combat"
// report for a Battle Shout rule.
if own {
collect_aura_slots(store, debuff_scratch, false);
if *own_buffs_seeded {
if own_buffs != debuff_scratch {
fire_debuff_edges(
&mut script,
guid.0,
catalog,
own_buffs,
debuff_scratch,
AuraEdge::OwnBuff,
);
own_buffs.clone_from(debuff_scratch);
}
} else {
own_buffs.clone_from(debuff_scratch);
*own_buffs_seeded = true;
}
}
collect_debuff_slots(store, debuff_scratch);
match debuffs.get_mut(e) {
Some(prev) => {
if prev != debuff_scratch {
fire_debuff_edges(
&mut script,
guid.0,
catalog,
prev,
debuff_scratch,
if own {
AuraEdge::OwnDebuff
} else {
AuraEdge::OtherDebuff
},
);
prev.clone_from(debuff_scratch);
}
}
// No seed: the arrival ran before this feed existed (a `/reload` of the host's own
// state cannot happen, but a unit can arrive in a frame this system skipped for want
// of an avatar). Seed now, fire nothing — the same rule as first sight.
None => {
debuffs.insert(*e, debuff_scratch.clone());
}
}
}
// Streamed units despawn on range-out; drop their rows, but only on a frame that did work, so
// an idle frame never walks the map.
if !due.is_empty() || !arrived.is_empty() {
debuffs.retain(|e, _| watched.contains(*e));
}
}
/// How long before its aura a duration packet may arrive and still join it. The measured lead is
/// one frame, about 50 ms, on a fresh apply; a recycled slot's stale stamp is seconds older.
const DURATION_SLACK: f64 = 1.0;
/// Joins a slot's stamp to the aura in that slot as `(duration, expirationTime)` on the script
/// clock, `(0, 0)` for none; only this rejects a stamp. A stamp older than the aura by more than
/// [`DURATION_SLACK`] is dropped as an earlier occupant's, where the reference's reader
/// (`0x4e4450`) has no freshness test.
fn join_duration(
stamp: Option<&DurationStamp>,
appeared_at: f64,
bevy_now: f64,
script_now: f64,
) -> (f64, f64) {
stamp
.filter(|d| d.received_at >= appeared_at - DURATION_SLACK)
.map(|d| (d.total, script_now + (d.expires_at - bevy_now)))
.unwrap_or((0.0, 0.0))
}
/// The session's end: drops the lists and stamps, so nothing reaches the next character's bar.
/// Deviation: the reference never clears its expiry array (`0xbc5f68`); this does, because vmangos
/// sends no durations at login to overwrite a kept stamp (`SMSG_UPDATE_AURA_DURATION` is an empty
/// placeholder in `Player::SendInitialPacketsBeforeAddToMap`, `Player.cpp:19248`).
fn end_session_aura_state(
script: Option<NonSendMut<UiScript>>,
mut durations: ResMut<AuraDurations>,
mut other_durations: ResMut<OtherAuraDurations>,
mut cache: ResMut<PlayerAuraCache>,
mut mem: ResMut<AuraFeedMemory>,
) {
if let Some(mut script) = script {
script.set_auras("player", None);
script.set_auras("target", None);
script.set_auras("targettarget", None);
script.set_auras("focus", None);
script.set_auras("focustarget", None);
for token in crate::ui_party::PARTY_TOKENS {
script.set_auras(token, None);
}
script.set_tracking(None);
}
cache.auras.clear();
durations.by_slot.clear();
// Every unit they name is gone with the session, and a guid is recycled across realms.
other_durations.by_aura.clear();
// No `AuraFeedMemory` *VM* reset: those edge keys die with the VM. The nampower debuff rows
// are host-side and keyed by entity, so they would otherwise sit there until the next
// session's first aura edge ran the despawn retain — dropped here instead, since every unit
// they name is gone.
mem.debuffs.clear();
mem.debuff_scratch.clear();
}
/// A `CMSG_CANCEL_AURA` per queued `CancelUnitBuff`, naming the spell, not the slot (`0x6e7040`).
fn drain_aura_cancels(script: Option<NonSendMut<UiScript>>, net: Res<NetCommands>) {
let Some(mut script) = script else {
return;
};
for spell_id in script.take_cancel_aura_requests() {
let _ = net.0.send(ClientCommand::CancelAura { spell_id });
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The plugin on a bare app: without a `UiScript` the systems that need one skip.
fn aura_app() -> App {
let (tx, _) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin))
.insert_state(ClientState::InWorld)
.init_resource::<Selection>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<GuidIndex>()
// A required system parameter, unlike `Factions`.
.init_resource::<Reputations>()
// Likewise: the feed reads the party roster for the four party tokens' lists.
.init_resource::<crate::ui_party::GroupState>()
.insert_resource(NetCommands(tx))
.add_plugins(UiAuraPlugin);
app
}
/// One live aura with a running timer: what a teleport must keep and a logout must drop.
fn seed_one_timed_aura(app: &mut App) {
app.world_mut()
.resource_mut::<AuraDurations>()
.set(32, 300_000, 10.0);
app.world_mut()
.resource_mut::<PlayerAuraCache>()
.auras
.push(CachedAura {
slot: 32, // the first harmful slot, a debuff
spell_id: 11976,
appeared_at: 10.0,
flags: 0x8,
level: 60,
stacks: 1,
});
}
/// A worldport's avatar-less frames keep every aura live, and no duration is re-sent then.
#[test]
fn the_aura_state_survives_an_avatar_less_frame_and_dies_only_with_the_session() {
let mut app = aura_app();
seed_one_timed_aura(&mut app);
// Frames with no avatar: the worldport gap.
app.update();
app.update();
assert_eq!(
app.world().resource::<PlayerAuraCache>().auras.len(),
1,
"the display cache survives the gap — its `appeared_at` is the freshness gate's anchor"
);
assert!(
app.world()
.resource::<AuraDurations>()
.by_slot
.contains_key(&32),
"the duration stamp survives the gap — nothing will re-send it"
);
// The session ends: `/logout` to the glue screens.
app.world_mut()
.resource_mut::<NextState<ClientState>>()
.set(ClientState::CharSelect);
app.update();
assert!(app.world().resource::<PlayerAuraCache>().auras.is_empty());
assert!(app.world().resource::<AuraDurations>().by_slot.is_empty());
}
fn slot(slot: u8, spell_id: u32) -> UnitAuraSlot {
UnitAuraSlot {
slot,
spell_id,
flags: if slot < UNIT_AURA_POSITIVE_SLOTS {
AURA_FLAG_CANCELABLE | 0x8
} else {
0x8
},
level: 60,
stacks: 1,
}
}
fn order(cache: &[CachedAura]) -> Vec<(u8, u32)> {
cache.iter().map(|c| (c.slot, c.spell_id)).collect()
}
/// Against the real `Spell.dbc`; skips without client data.
#[test]
fn the_aura_display_filter_hides_a_real_battle_stance_but_keeps_battle_shout() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc");
// Battle Stance 2457 and Battle Shout 6673.
let slots = [slot(0, 2457), slot(1, 6673)];
let shown: Vec<u32> = slots
.iter()
.filter(|a| shown_in_aura_ui(Some(&catalog), a.spell_id))
.map(|a| a.spell_id)
.collect();
assert_eq!(shown, [6673], "the stance is filtered, the shout stays");
// Fail-open: no catalog at all, or an id the catalog can't resolve, stays visible.
assert!(shown_in_aura_ui(None, 2457));
assert!(shown_in_aura_ui(Some(&catalog), 0xffff_fffe));
}
/// Against the real `Spell.dbc` (`0x4e42d6`-`0x4e4308`); skips without client data.
#[test]
fn a_real_tracking_aura_is_diverted_from_the_bar_to_the_tracking_state() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc");
// Find Herbs 2383 (aura 45), Battle Shout 6673, Track Beasts 1494 (aura 44).
let slots = [slot(0, 2383), slot(1, 6673), slot(2, 1494)];
let shown: Vec<u32> = slots
.iter()
.filter(|a| shown_in_aura_ui(Some(&catalog), a.spell_id))
.map(|a| a.spell_id)
.collect();
assert_eq!(
shown,
[6673],
"both tracking auras are diverted, the shout stays"
);
// The last tracking aura in slot order holds the global.
let t = tracking_state_of(Some(&catalog), &slots).expect("tracking state set");
assert_eq!(
t.spell_id, 1494,
"slot 2's Track Beasts overwrites slot 0's Find Herbs"
);
assert_eq!(t.name.as_deref(), Some("Track Beasts"));
assert!(t.icon.is_some(), "the icon path GetTrackingTexture returns");
assert!(
t.cancelable,
"the synthesized slot carries AFLAG_CANCELABLE"
);
// No tracking aura, or no catalog to identify one: no tracking state.
assert!(tracking_state_of(Some(&catalog), &[slot(0, 6673)]).is_none());
assert!(tracking_state_of(None, &slots).is_none());
}
#[test]
fn a_new_low_slot_aura_appends_at_the_end_not_sorted_by_slot() {
let mut cache = Vec::new();
// X lands in slot 5 first.
reconcile(&mut cache, &[slot(5, 100)], 1.0);
assert_eq!(order(&cache), [(5, 100)]);
// Y lands in slot 2 later: the descriptor reads [2, 5], but Y is newer.
reconcile(&mut cache, &[slot(2, 200), slot(5, 100)], 2.0);
assert_eq!(
order(&cache),
[(5, 100), (2, 200)],
"insertion order, not ascending slot"
);
}
/// `PlayerAuras_Update`'s shift-down (`0x4e421b`), then a recycled slot's append.
#[test]
fn a_dropped_aura_repacks_and_a_recycled_slot_appends_fresh() {
let mut cache = Vec::new();
reconcile(&mut cache, &[slot(0, 10), slot(1, 20), slot(2, 30)], 1.0);
assert_eq!(order(&cache), [(0, 10), (1, 20), (2, 30)]);
// The middle aura (slot 1) drops.
reconcile(&mut cache, &[slot(0, 10), slot(2, 30)], 2.0);
assert_eq!(order(&cache), [(0, 10), (2, 30)], "the gap closes");
// A new spell takes slot 1: it appends, not back into the old middle.
reconcile(&mut cache, &[slot(0, 10), slot(1, 99), slot(2, 30)], 3.0);
assert_eq!(
order(&cache),
[(0, 10), (2, 30), (1, 99)],
"the recycled slot is the newest, so it is last"
);
}
#[test]
fn a_surviving_aura_refreshes_in_place() {
let mut cache = Vec::new();
reconcile(&mut cache, &[slot(0, 10), slot(1, 20)], 1.0);
let mut restacked = slot(1, 20);
restacked.stacks = 5;
reconcile(&mut cache, &[slot(0, 10), restacked], 2.0);
assert_eq!(order(&cache), [(0, 10), (1, 20)], "position unchanged");
assert_eq!(cache[1].stacks, 5, "stack count refreshed");
assert_eq!(cache[0].appeared_at, 1.0, "appeared_at is not disturbed");
}
#[test]
fn a_duration_is_joined_only_when_it_is_no_older_than_the_aura() {
// Received at t=100 for an aura that appeared at t=200: a previous occupant's.
let stale = DurationStamp {
total: 30.0,
expires_at: 130.0,
received_at: 100.0,
};
assert_eq!(
join_duration(Some(&stale), 200.0, 200.0, 5000.0),
(0.0, 0.0),
"a stamp seconds older than the aura is rejected — no timer, not a wrong one"
);
// Received just before the aura: joined, and rebased onto the script clock.
let fresh = DurationStamp {
total: 30.0,
expires_at: 230.0,
received_at: 199.9,
};
assert_eq!(
join_duration(Some(&fresh), 200.0, 200.0, 5000.0),
(30.0, 5030.0)
);
// No stamp: a permanent aura, which gets no packet.
assert_eq!(join_duration(None, 200.0, 200.0, 5000.0), (0.0, 0.0));
}
/// The duration packet arrives about a frame before the delta that fills its slot.
#[test]
fn a_stamp_that_arrives_one_frame_before_its_aura_still_joins_it() {
let mut durations = AuraDurations::default();
// t = 15.10: the packet lands while slot 0 is still empty.
durations.set(0, 300_000, 15.10);
let mut cache = Vec::new();
assert!(cache.is_empty());
// t = 15.15: the next frame's descriptor delta fills slot 0.
reconcile(&mut cache, &[slot(0, 1126)], 15.15);
let (total, expiry) = join_duration(
durations.by_slot.get(&0),
cache[0].appeared_at,
15.15,
900.0,
);
assert_eq!(total, 300.0, "the stamp survived the gap");
// Script clock 900 plus the 300 s that began 50 ms ago, at the packet, not the delta.
assert!(
(expiry - 1199.95).abs() < 1e-9,
"expiry rebased onto the script clock, got {expiry}"
);
}
// == `UnitBuff`'s unit-level gate (`0x519500`) ==
// A stealthed Ridge Stalker carries 5916 in a positive slot and 22766 in a negative one; the
// reference draws only 22766, as `UnitBuff` lists no hostile creature's buffs.
/// `UNIT_FIELD_FLAGS`'s index.
const F_FLAGS: u16 = 46;
/// `UNIT_FIELD_CHARMEDBY`'s low dword; the high half is the next index.
const F_CHARMEDBY: u16 = 10;
/// The Ridge Stalker's guid, for the aura-timer join.
const RIDGE_STALKER: u64 = 0xf130_0001_0000_0001;
/// Our own guid: its auras are stamped by slot off the wire, not by this table.
const SELF_GUID: u64 = 0x0000_0000_0000_002a;
/// The Ridge Stalker's two slots: 5916 helpful, 22766 harmful.
fn ridge_stalker_slots(flags: u32) -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(F_FLAGS, flags),
(47, 5916), // UNIT_FIELD_AURA slot 0, the positive half
(47 + 32, 22766), // slot 32, the negative half
(95, 0x0000_0008), // AURAFLAGS slot 0 nibble 0x8 (eff0)
(95 + 4, 0x0000_0008), // slot 32 nibble 0x8
]))
}
/// Denied, `UnitBuff` is nil at every index; `UnitDebuff` is untouched.
#[test]
fn the_unit_gate_drops_the_whole_helpful_half_and_never_the_harmful_one() {
let store = ridge_stalker_slots(0);
let timers = OtherAuraDurations::default();
let clock = AuraClock {
timers: &timers,
bevy_now: 0.0,
script_now: 0.0,
};
let denied = other_unit_auras(&store, RIDGE_STALKER, None, false, clock);
assert_eq!(
denied.iter().map(|a| a.spell_id).collect::<Vec<_>>(),
[22766],
"a hostile creature shows its debuffs and NOT its buffs — `UnitBuff`'s unit gate"
);
assert!(denied.iter().all(|a| !a.helpful));
let allowed = other_unit_auras(&store, RIDGE_STALKER, None, true, clock);
assert_eq!(
allowed.iter().map(|a| a.spell_id).collect::<Vec<_>>(),
[5916, 22766],
"with the gate open both halves enumerate, ascending slot"
);
}
/// The defect grom reported from play: **no buffs on party members in the party frames**.
///
/// This feed pushed six tokens — player, target, pet, targettarget and ClassicAPI's focus pair
/// — and not one party token, so `UnitBuff("party1", i)` found no list at all and every party
/// buff row stayed empty, for pfUI's group header and for the stock
/// `PartyMemberFrame_UpdateAuras` alike. Nothing was wrong with `UnitBuff`; the state it reads
/// was never written.
#[test]
fn a_party_member_gets_its_own_aura_list_and_its_own_unit_aura_fire() {
const ME: u64 = 0x0000_0000_0000_0001;
const MATE: u64 = 0xF130_0000_9700_0009;
let mut app = aura_app();
let me = app
.world_mut()
.spawn((
crate::net::SelfPlayer,
ObjectStore(benilla_protocol::ObjectFields::default()),
crate::net::Guid(ME),
))
.id();
// Auras visible, so the helpful half enumerates and the list is not empty by gate.
let mate = app
.world_mut()
.spawn(ridge_stalker_slots(UNIT_FLAG_AURAS_VISIBLE))
.id();
let mut index = app.world_mut().resource_mut::<GuidIndex>();
index.0.insert(ME, me);
index.0.insert(MATE, mate);
// One party member in slot 1: same subgroup as us, which is what `party_slots` filters on.
app.world_mut()
.resource_mut::<crate::ui_party::GroupState>()
.members = vec![benilla_protocol::messages::GroupMemberEntry {
name: "Mate".into(),
guid: MATE,
status: 1,
flags: 0,
}];
let script = UiScript::new().unwrap();
script
.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("UNIT_AURA")
f:SetScript("OnEvent", function() table.insert(SEEN, arg1) end)
"#,
)
.unwrap();
app.insert_non_send_resource(script);
app.update();
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
// By spell id, as the focus test does: this app has no `Spells` catalog, so no aura
// resolves an icon and `UnitBuff`'s texture return would be nil either way.
let ids = |script: &mut UiScript, filter: &str| -> Vec<u32> {
script
.eval::<Vec<u32>>(&format!(
r#"local out = {{}} local i = 1
while true do
local a = C_UnitAuras.GetAuraDataByIndex("party1", i, "{filter}")
if not a then break end
table.insert(out, a.spellId) i = i + 1
end
return out"#
))
.unwrap()
};
assert_eq!(
(ids(&mut script, "HELPFUL"), ids(&mut script, "HARMFUL")),
(vec![5916], vec![22766]),
"party1 enumerates that member's own auras"
);
// And the verb the party frames actually call, which is what was empty in play. By
// **arity**, not by the texture: `UnitBuff` answers `(texture, stacks, ...)` and the
// texture comes from the spell catalog, which this app has no `Spells` for — so the icon
// would be nil here whatever the list holds. Arity is the whole question anyway: nothing
// at all is what `nth_aura` returns for a token with no list, and nothing at all is what
// pfUI's `if texture then` and the stock `PartyMemberFrame_UpdateAuras` loop stop on.
assert!(
script.arity(r#"UnitBuff("party1", 1)"#).unwrap() > 0,
"UnitBuff answers for party1 instead of returning nothing"
);
assert_eq!(
script.arity(r#"UnitBuff("party2", 1)"#).unwrap(),
0,
"an empty slot still answers nothing"
);
let seen = script.eval::<Vec<String>>("return SEEN").unwrap();
assert!(
seen.iter().any(|t| t == "party1"),
"the party frame hears only its own token: {seen:?}"
);
}
/// ClassicAPI's focus (`docs/ADDON_COMPAT.md`): pfUI's focus frame draws debuff buttons and
/// hears only `UNIT_AURA` with its OWN token — the `"target"` fire tells it nothing — so the
/// list has to be pushed per token and the event fired per token.
#[test]
fn the_focus_token_gets_its_own_aura_list_and_its_own_unit_aura_fire() {
const ME: u64 = 0x0000_0000_0000_0001;
const STALKER: u64 = 0xF130_0000_9700_0005;
let mut app = aura_app();
let me = app
.world_mut()
.spawn((
crate::net::SelfPlayer,
ObjectStore(benilla_protocol::ObjectFields::default()),
crate::net::Guid(ME),
))
.id();
// Auras visible, so the helpful half enumerates too and the list is not empty by gate.
let stalker = app
.world_mut()
.spawn(ridge_stalker_slots(UNIT_FLAG_AURAS_VISIBLE))
.id();
let mut index = app.world_mut().resource_mut::<GuidIndex>();
index.0.insert(ME, me);
index.0.insert(STALKER, stalker);
let mut script = UiScript::new().unwrap();
// The focus is stored as a guid, so `FocusUnit` needs some token naming the unit first.
script.set_unit(
"target",
Some(benilla_ui::script::UnitState {
exists: true,
guid: STALKER,
..Default::default()
}),
);
script
.run(
r#"
SEEN = {}
local f = CreateFrame("Frame")
f:RegisterEvent("UNIT_AURA")
f:SetScript("OnEvent", function() table.insert(SEEN, arg1) end)
"#,
)
.unwrap();
script.run(r#"FocusUnit("target")"#).unwrap();
app.insert_non_send_resource(script);
app.update();
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
// The Ridge Stalker carries one of each half and the gate is open, so both enumerate.
// Read by spell id (`C_UnitAuras`' row), not through `UnitDebuff`'s texture return: this
// app has no `Spells` catalog, so no aura resolves an icon.
let ids = |script: &mut UiScript, filter: &str| -> Vec<u32> {
script
.eval::<Vec<u32>>(&format!(
r#"local out = {{}} local i = 1
while true do
local a = C_UnitAuras.GetAuraDataByIndex("focus", i, "{filter}")
if not a then break end
table.insert(out, a.spellId) i = i + 1
end
return out"#
))
.unwrap()
};
assert_eq!(
(ids(&mut script, "HARMFUL"), ids(&mut script, "HELPFUL")),
(vec![22766], vec![5916]),
"the focus token enumerates the focused unit's own auras, not the target's"
);
let seen = script.eval::<Vec<String>>("return SEEN").unwrap();
assert!(
seen.iter().any(|t| t == "focus"),
"UNIT_AURA must name the focus token; got {seen:?}"
);
}
/// vmangos sets `UNIT_FLAG_AURAS_VISIBLE` for a GM viewer too (`Object.cpp:765-766`).
#[test]
fn detect_magic_or_gm_mode_opens_the_gate_on_the_flag_alone() {
let reputations = Reputations::default();
// No factions catalog: the reaction is neutral (3), below the `>= 4` bar.
let hostile = ridge_stalker_slots(0);
assert!(
!buffs_visible_on(&hostile, None, None, &reputations, |_| None),
"neutral reaction, not player-controlled, no PvP flag → no buffs"
);
let detected = ridge_stalker_slots(UNIT_FLAG_AURAS_VISIBLE);
assert!(
buffs_visible_on(&detected, None, None, &reputations, |_| None),
"UNIT_FLAG_AURAS_VISIBLE alone opens it, whatever the reaction says"
);
}
/// `NOT_SELECTABLE` is `CanAssist`'s; the charm clause is `UnitBuff`'s (`0x5195f1`-`0x5195f7`).
#[test]
fn not_selectable_and_being_charmed_each_close_the_gate_on_their_own() {
let reputations = Reputations::default();
// AURAS_VISIBLE is tested before `CanAssist`, so it wins over NOT_SELECTABLE.
let both = ridge_stalker_slots(UNIT_FLAG_AURAS_VISIBLE | (1 << 25));
assert!(
buffs_visible_on(&both, None, None, &reputations, |_| None),
"AURAS_VISIBLE is tested FIRST and short-circuits CanAssist entirely"
);
// Being charmed closes the gate before `CanAssist` runs.
let friendly_pc = ridge_stalker_slots(0x8);
let charmed_self = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(F_CHARMEDBY, 0x1234),
(F_CHARMEDBY + 1, 0),
]));
assert!(
!buffs_visible_on(
&friendly_pc,
Some(&charmed_self),
None,
&reputations,
|_| None
),
"activePlayer.CHARMEDBY != 0 → no buffs on anyone"
);
}
/// The two clauses of `0x4e452e`-`0x4e45c5`.
#[test]
fn until_cancelled_follows_the_duration_row_and_the_area_aura_override() {
use benilla_formats::{SpellDisplay, SpellDuration};
let row = |base_ms: i32, per_level_ms: i32| SpellDuration {
base_ms,
per_level_ms,
max_ms: base_ms,
};
// Real `SpellDuration.dbc` rows: 21 `{-1, 0}` permanent, 30 `{1_800_000, 0}` 30 minutes,
// 427 `{-600_000, 60_000}`, whose negative base alone is not permanence.
let permanent_row = row(-1, 0);
let timed_row = row(1_800_000, 0);
let scaling_row = row(-600_000, 60_000);
let spell = |effects: [u32; 3]| SpellDisplay {
effects,
..SpellDisplay::default()
};
let ordinary = spell([6, 0, 0]);
// 0x23 is `SPELL_EFFECT_APPLY_AREA_AURA_PARTY`, one of the four in the override set.
let area = spell([0x23, 0, 0]);
// Clause 1: the duration row decides, cancelable or not.
assert!(!until_cancelled(
Some(&ordinary),
Some(&timed_row),
true,
0.0
));
assert!(!until_cancelled(
Some(&ordinary),
Some(&timed_row),
false,
0.0
));
assert!(until_cancelled(
Some(&ordinary),
Some(&permanent_row),
true,
0.0
));
assert!(
!until_cancelled(Some(&ordinary), Some(&scaling_row), true, 0.0),
"a negative base with a positive per-level term is a SCALING duration, not permanence"
);
// No row: permanent (`0x4e4580`).
assert!(until_cancelled(Some(&ordinary), None, true, 0.0));
// Clause 2: the area-aura override applies only to a non-cancelable aura (`0x4e4588`).
assert!(until_cancelled(Some(&area), Some(&timed_row), false, 0.0));
assert!(
!until_cancelled(Some(&area), Some(&timed_row), true, 0.0),
"a CANCELABLE aura skips the effect scan entirely"
);
// A catalog miss answers from the joined expiry.
assert!(until_cancelled(None, None, false, 0.0));
assert!(!until_cancelled(None, None, false, 1234.0));
}
// ─── nampower v2's DEBUFF_ADDED_OTHER / DEBUFF_REMOVED_OTHER ─────────────────────────────
/// pfUI's own two handler heads, transcribed from `pfUI/libs/libdebuff.lua:1343-1351`
/// (`DEBUFF_ADDED_OTHER`) and `:1474-1481` (`DEBUFF_REMOVED_OTHER`), including the
/// `arg6 + 1` conversion it does because nampower reports the slot 0-based — and the
/// unguarded `GetPlayerGuid()` at `:1407`, which the handler needs to decide ownership.
///
/// The whole reason this is a transcription and not an argument list: a positional shape got
/// backwards binds `displaySlot` to the spell id and tracks debuffs that do not exist, in
/// silence. Reading the consumer's own `local`s back out is the only assertion that catches it.
const PFUI_DEBUFF_EVENTS: &str = r#"
seen = {}
local f = CreateFrame("Frame")
f:RegisterEvent("DEBUFF_ADDED_OTHER")
f:RegisterEvent("DEBUFF_REMOVED_OTHER")
f:SetScript("OnEvent", function()
local guid = arg1
local displaySlot = arg2
local spellId = arg3
local stacks = arg4
local auraLevel = arg5
local auraSlot_0based = arg6
local state = arg7
-- libdebuff.lua:1350 / :1480 — 0-based event slot to the 1-based Lua array it indexes.
local auraSlot = auraSlot_0based and (auraSlot_0based + 1) or nil
local myGuid = GetPlayerGuid()
table.insert(seen, string.format("%s|%s|%d|%d|%d|%d|%d|%d|%s",
event, guid, displaySlot, spellId, stacks, auraLevel, auraSlot_0based, auraSlot,
tostring(myGuid)))
end)
"#;
const PLAYER: u64 = 0x0000_0000_0000_002a;
const STALKER_GUID: u64 = 0xF130_0000_9700_0005;
/// `UNIT_FIELD_AURA`'s base index, and the two packed-byte arrays beside it.
const F_AURA: u16 = 47;
const F_AURAFLAGS: u16 = 95;
const F_AURALEVELS: u16 = 101;
const F_AURAAPPLICATIONS: u16 = 113;
/// One unit's descriptor carrying `(slot, spell_id, stacks, level)` debuffs, packed exactly as
/// the wire does: the id a dword per slot, the `AURAFLAGS` effect-index nibble eight to a
/// dword (without it `unit_aura` reads the slot as empty, whatever the id says), and the level
/// and stack count a byte each four to a dword, the stack byte holding `stacks - 1`.
fn unit_with_debuffs(rows: &[(u8, u32, u8, u8)]) -> ObjectStore {
let mut pairs: Vec<(u16, u32)> = vec![(F_FLAGS, 0)];
let mut flags = std::collections::BTreeMap::<u16, u32>::new();
let mut levels = std::collections::BTreeMap::<u16, u32>::new();
let mut apps = std::collections::BTreeMap::<u16, u32>::new();
for &(slot, spell_id, stacks, level) in rows {
pairs.push((F_AURA + u16::from(slot), spell_id));
// 0x8 is the nibble `ridge_stalker_slots` above uses: effect index 0, occupied.
*flags.entry(F_AURAFLAGS + u16::from(slot >> 3)).or_default() |=
0x8 << (u32::from(slot & 7) * 4);
let shift = u32::from(slot & 3) * 8;
*levels
.entry(F_AURALEVELS + u16::from(slot >> 2))
.or_default() |= u32::from(level) << shift;
*apps
.entry(F_AURAAPPLICATIONS + u16::from(slot >> 2))
.or_default() |= u32::from(stacks.saturating_sub(1)) << shift;
}
pairs.extend(flags);
pairs.extend(levels);
pairs.extend(apps);
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&pairs))
}
/// The feed with an avatar, one streamed mob and the pfUI spy installed.
fn debuff_app() -> (App, Entity) {
let mut app = aura_app();
let me = app
.world_mut()
.spawn((
crate::net::SelfPlayer,
ObjectStore(benilla_protocol::ObjectFields::default()),
crate::net::Guid(PLAYER),
))
.id();
let mob = app
.world_mut()
.spawn((unit_with_debuffs(&[]), crate::net::Guid(STALKER_GUID)))
.id();
let mut index = app.world_mut().resource_mut::<GuidIndex>();
index.0.insert(PLAYER, me);
index.0.insert(STALKER_GUID, mob);
let mut script = UiScript::new().unwrap();
script.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
guid: PLAYER,
..Default::default()
}),
);
script.run(PFUI_DEBUFF_EVENTS).unwrap();
app.insert_non_send_resource(script);
// The arrival frame: both units are `Added<ObjectStore>` here, so the memory seeds.
app.update();
(app, mob)
}
/// Re-stream a unit's aura block and raise the field edges the net path would raise for it.
fn aura_update(app: &mut App, entity: Entity, guid: u64, rows: &[(u8, u32, u8, u8)]) {
*app.world_mut().get_mut::<ObjectStore>(entity).unwrap() = unit_with_debuffs(rows);
// One edge per slot dword that could have moved; the feed only needs one in the span.
for slot in UNIT_AURA_POSITIVE_SLOTS..benilla_protocol::messages::UNIT_AURA_SLOTS {
app.world_mut().write_message(crate::net::FieldChanged {
entity,
guid,
kind: benilla_protocol::messages::ObjectType::Unit,
index: F_AURA + u16::from(slot),
old: 0,
new: 0,
});
}
app.update();
}
fn seen(app: &mut App) -> Vec<String> {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<Vec<String>>("local s = seen seen = {} return s")
.unwrap()
}
#[test]
fn a_debuff_appearing_on_another_unit_lands_libdebuffs_own_seven_arguments() {
let (mut app, mob) = debuff_app();
// Corruption in the first harmful slot, three stacks, cast by a level-60 caster.
aura_update(&mut app, mob, STALKER_GUID, &[(32, 172, 3, 60)]);
let guid = benilla_ui::script::guid_token(STALKER_GUID);
let me = benilla_ui::script::guid_token(PLAYER);
assert_eq!(
seen(&mut app),
vec![format!("DEBUFF_ADDED_OTHER|{guid}|1|172|3|60|32|33|{me}")],
"arg1 guid, arg2 luaSlot (1-based, the first debuff), arg3 spellId, arg4 stackCount, \
arg5 auraLevel, arg6 auraSlot 0-based — which pfUI reads as 33 after its +1"
);
// It drops: the same seven arguments, the removal word, and `state` 1.
aura_update(&mut app, mob, STALKER_GUID, &[]);
assert_eq!(
seen(&mut app),
vec![format!("DEBUFF_REMOVED_OTHER|{guid}|1|172|3|60|32|33|{me}")],
"a removed aura's luaSlot is the index it HAD: it is gone from the current list"
);
}
/// Upstream's `state` legend: 0 newly added, 1 newly removed. Read off the raw `arg7` rather
/// than through pfUI, which ignores it.
#[test]
fn the_state_argument_says_added_or_removed() {
let (mut app, mob) = debuff_app();
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run(
r#"
states = {}
local g = CreateFrame("Frame")
g:RegisterEvent("DEBUFF_ADDED_OTHER")
g:RegisterEvent("DEBUFF_REMOVED_OTHER")
g:SetScript("OnEvent", function()
table.insert(states, event .. "=" .. arg7)
end)
"#,
)
.unwrap();
aura_update(&mut app, mob, STALKER_GUID, &[(32, 172, 1, 60)]);
aura_update(&mut app, mob, STALKER_GUID, &[]);
assert_eq!(
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<Vec<String>>("return states")
.unwrap(),
vec![
"DEBUFF_ADDED_OTHER=0".to_string(),
"DEBUFF_REMOVED_OTHER=1".to_string()
]
);
}
/// `luaSlot` is the `UnitDebuff` index, so it counts occupied debuff slots below this one.
#[test]
fn the_lua_slot_is_the_unit_debuff_index_and_not_the_raw_slot() {
let (mut app, mob) = debuff_app();
aura_update(
&mut app,
mob,
STALKER_GUID,
&[(32, 172, 1, 60), (34, 589, 1, 60), (35, 980, 1, 60)],
);
let rows = seen(&mut app);
let slots: Vec<&str> = rows.iter().map(|r| r.split('|').nth(2).unwrap()).collect();
assert_eq!(
slots,
["1", "2", "3"],
"raw slots 32, 34, 35 are `UnitDebuff` 1, 2 and 3: the gap at 33 is skipped"
);
let raw: Vec<&str> = rows.iter().map(|r| r.split('|').nth(6).unwrap()).collect();
assert_eq!(raw, ["32", "34", "35"], "arg6 is the raw slot, unchanged");
}
/// A unit streamed in with debuffs already on it raises **no** field edge — its aura ids
/// arrive with the object — and must raise no event either: nothing here watched them land,
/// the descriptor keeps no caster for them, and a crowd would otherwise cost a burst of Lua
/// dispatch on arrival. The seed is what makes the *next* real application an add.
#[test]
fn a_unit_streamed_in_mid_debuff_seeds_the_memory_and_fires_nothing() {
let mut app = aura_app();
let me = app
.world_mut()
.spawn((
crate::net::SelfPlayer,
ObjectStore(benilla_protocol::ObjectFields::default()),
crate::net::Guid(PLAYER),
))
.id();
let mob = app
.world_mut()
.spawn((
unit_with_debuffs(&[(32, 172, 1, 60)]),
crate::net::Guid(STALKER_GUID),
))
.id();
let mut index = app.world_mut().resource_mut::<GuidIndex>();
index.0.insert(PLAYER, me);
index.0.insert(STALKER_GUID, mob);
let mut script = UiScript::new().unwrap();
script.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
guid: PLAYER,
..Default::default()
}),
);
script.run(PFUI_DEBUFF_EVENTS).unwrap();
app.insert_non_send_resource(script);
app.update();
assert!(
seen(&mut app).is_empty(),
"an aura that was already there was not newly added"
);
// The next application is an add, from the seeded state and not from empty.
aura_update(
&mut app,
mob,
STALKER_GUID,
&[(32, 172, 1, 60), (33, 589, 1, 60)],
);
let rows = seen(&mut app);
assert_eq!(rows.len(), 1, "only the new one: {rows:?}");
assert!(rows[0].contains("|589|"));
}
/// One descriptor delta can recycle a slot. pfUI keys its ownership table on the raw slot and
/// **clears that key on removal** (`libdebuff.lua:1507-1512`), so the removal has to reach it
/// before the arrival that takes the slot — otherwise the clear wipes the new owner.
#[test]
fn a_slot_recycled_in_one_delta_reports_its_removal_before_the_arrival() {
let (mut app, mob) = debuff_app();
aura_update(&mut app, mob, STALKER_GUID, &[(32, 172, 1, 60)]);
let _ = seen(&mut app);
aura_update(&mut app, mob, STALKER_GUID, &[(32, 589, 1, 60)]);
let rows = seen(&mut app);
assert_eq!(rows.len(), 2, "{rows:?}");
assert!(
rows[0].starts_with("DEBUFF_REMOVED_OTHER") && rows[0].contains("|172|"),
"the old occupant's removal first: {rows:?}"
);
assert!(
rows[1].starts_with("DEBUFF_ADDED_OTHER") && rows[1].contains("|589|"),
"then the new one: {rows:?}"
);
}
/// The player's own debuffs are `DEBUFF_ADDED_SELF`/`_REMOVED_SELF`, a pair this client does
/// not fire. The `*_OTHER` half must not answer for the player, or pfUI would track its own
/// debuffs under the wrong event.
#[test]
fn the_players_own_debuff_block_raises_no_other_event() {
let (mut app, _mob) = debuff_app();
let me = app
.world_mut()
.query_filtered::<Entity, With<crate::net::SelfPlayer>>()
.single(app.world())
.unwrap();
aura_update(&mut app, me, PLAYER, &[(32, 172, 1, 60)]);
assert!(seen(&mut app).is_empty());
}
/// The cost property, stated as a test: no aura-id field edge, no work and no event — even
/// though the descriptor itself changed. This is what keeps the pair off the per-frame budget.
#[test]
fn a_descriptor_change_with_no_aura_field_edge_fires_nothing() {
let (mut app, mob) = debuff_app();
*app.world_mut().get_mut::<ObjectStore>(mob).unwrap() =
unit_with_debuffs(&[(32, 172, 1, 60)]);
// An edge outside the watched span: health, not an aura id.
app.world_mut().write_message(crate::net::FieldChanged {
entity: mob,
guid: STALKER_GUID,
kind: benilla_protocol::messages::ObjectType::Unit,
index: 22, // UNIT_FIELD_HEALTH
old: 100,
new: 50,
});
app.update();
assert!(
seen(&mut app).is_empty(),
"the pair is driven by the aura-id edges and nothing else"
);
}
/// A stack count moving inside a slot that keeps its spell is a refresh, and upstream fires
/// neither of this pair for one — pfUI says so from its own side ("DEBUFF_ADDED doesn't fire
/// on refresh", `libdebuff.lua:1427`), patching the gap off `AURA_CAST` instead.
#[test]
fn a_stack_change_inside_a_slot_that_keeps_its_spell_is_not_an_edge() {
let (mut app, mob) = debuff_app();
aura_update(&mut app, mob, STALKER_GUID, &[(32, 172, 1, 60)]);
let _ = seen(&mut app);
aura_update(&mut app, mob, STALKER_GUID, &[(32, 172, 5, 60)]);
assert!(seen(&mut app).is_empty());
}
/// `luaSlot` 0 is upstream's own answer for an aura that is itself hidden, and a hidden aura
/// still takes a raw slot — so it must not shift the index of the ones below it either.
#[test]
fn a_hidden_aura_reports_lua_slot_zero_and_does_not_shift_the_others() {
use benilla_formats::{SpellCatalog, SpellDisplay};
// 0x80 is `SPELL_ATTR_DO_NOT_DISPLAY`: languages, proficiencies, internal proc auras.
let catalog = SpellCatalog::from_displays(
[
(
172,
SpellDisplay {
attributes: 0x80,
..Default::default()
},
),
(589, SpellDisplay::default()),
]
.into_iter()
.collect(),
);
let visible = [
DebuffSlot {
slot: 32,
spell_id: 172,
stacks: 1,
level: 60,
},
DebuffSlot {
slot: 33,
spell_id: 589,
stacks: 1,
level: 60,
},
];
assert_eq!(
debuff_lua_slot(Some(&catalog), &visible, 32),
0,
"the triggering aura is hidden"
);
assert_eq!(
debuff_lua_slot(Some(&catalog), &visible, 33),
1,
"the hidden aura above it is skipped, as `UnitDebuff` skips it"
);
assert_eq!(
debuff_lua_slot(None, &visible, 33),
2,
"no catalogue: nothing is hidden, so the index counts both"
);
assert_eq!(
debuff_lua_slot(None, &visible, 40),
0,
"a slot not in the list has no index"
);
}
// ─── another unit's aura timers, learned from the GO ──────────────────────────────────────
/// Rend rank 1, the debuff the gap was reported against: one `APPLY_AURA` effect,
/// `PERIODIC_DAMAGE`, on a fixed 9 s row.
const REND: u32 = 772;
/// Rupture, whose `SpellDuration.dbc` row scales with combo points: this client states no
/// length for it at all.
const RUPTURE: u32 = 1943;
/// A pure-damage spell: no aura effect, so nothing to time.
const SHOOT: u32 = 3018;
const DUR_FIXED: u32 = 9;
const DUR_SCALING: u32 = 86;
fn timer_spells() -> Spells {
use benilla_formats::{SpellDisplay, SpellDuration};
let mut spells = Spells::empty_for_tests();
spells.catalog = SpellCatalog::from_displays(
[
(
REND,
SpellDisplay {
name: "Rend".into(),
effects: [6, 0, 0],
effect_apply_aura: [3, 0, 0],
duration_index: DUR_FIXED,
..Default::default()
},
),
(
RUPTURE,
SpellDisplay {
name: "Rupture".into(),
effects: [6, 0, 0],
effect_apply_aura: [3, 0, 0],
duration_index: DUR_SCALING,
..Default::default()
},
),
(
SHOOT,
SpellDisplay {
name: "Shoot".into(),
effects: [2, 0, 0],
effect_apply_aura: [0, 0, 0],
duration_index: DUR_FIXED,
..Default::default()
},
),
]
.into_iter()
.collect(),
);
spells.durations = benilla_formats::SpellDurationCatalog::from_rows([
(
DUR_FIXED,
SpellDuration {
base_ms: 9_000,
per_level_ms: 0,
max_ms: 9_000,
},
),
(
DUR_SCALING,
SpellDuration {
base_ms: 8_000,
per_level_ms: 2_000,
max_ms: 16_000,
},
),
]);
spells
}
/// One harmful slot holding `spell`, with the gate open.
fn one_debuff(spell: u32) -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(F_FLAGS, 0),
(47 + 32, spell),
(95 + 4, 0x0000_0008),
]))
}
/// The reported gap: a target's debuff icon had no countdown, because the 1.12 wire sends
/// durations for the player alone and nothing here filled the hole. The GO that applied the
/// aura is the hole's filler — it names the spell, the hit list and the moment.
#[test]
fn a_targets_debuff_timer_comes_from_the_go_that_applied_it() {
let spells = timer_spells();
let mut timers = OtherAuraDurations::default();
timers.record_spell_go(
&[RIDGE_STALKER],
REND,
Some(SELF_GUID),
Some(&spells),
100.0,
);
// Two seconds later, on a script clock skewed from the app's by a thousand.
let clock = AuraClock {
timers: &timers,
bevy_now: 102.0,
script_now: 1_102.0,
};
let list = other_unit_auras(&one_debuff(REND), RIDGE_STALKER, None, true, clock);
assert_eq!(list.len(), 1);
assert_eq!(list[0].duration, 9.0, "the `SpellDuration.dbc` length");
assert_eq!(
list[0].expiration_time, 1_109.0,
"seven of the nine seconds left, on `GetTime()`'s clock and not the app's"
);
}
/// A stamp is keyed by spell, so a re-cast refreshes it in place rather than queueing a
/// second entry an icon could read the older of.
#[test]
fn a_recast_moves_the_same_stamp_rather_than_adding_one() {
let spells = timer_spells();
let mut timers = OtherAuraDurations::default();
timers.record_spell_go(
&[RIDGE_STALKER],
REND,
Some(SELF_GUID),
Some(&spells),
100.0,
);
timers.record_spell_go(
&[RIDGE_STALKER],
REND,
Some(SELF_GUID),
Some(&spells),
105.0,
);
assert_eq!(timers.by_aura.len(), 1);
let clock = AuraClock {
timers: &timers,
bevy_now: 105.0,
script_now: 105.0,
};
let list = other_unit_auras(&one_debuff(REND), RIDGE_STALKER, None, true, clock);
assert_eq!(
list[0].expiration_time, 114.0,
"the refresh's own 9 s, not the first cast's remaining 4"
);
}
/// The three cases that must stay timerless, each for its own reason — a wrong countdown is
/// worse than none, which is the trade `docs/ADDON_COMPAT.md` records for nampower's `arg8`.
#[test]
fn nothing_is_stamped_for_a_scaling_row_a_pure_damage_spell_or_the_player() {
let spells = timer_spells();
let mut timers = OtherAuraDurations::default();
// A combo-point row: `Duration[0]` alone would quote 8 s for an 18 s five-point Rupture.
timers.record_spell_go(
&[RIDGE_STALKER],
RUPTURE,
Some(SELF_GUID),
Some(&spells),
100.0,
);
// No aura effect: a `DurationIndex` for the visual is not an aura length.
timers.record_spell_go(
&[RIDGE_STALKER],
SHOOT,
Some(SELF_GUID),
Some(&spells),
100.0,
);
// Our own auras are stamped by slot from `SMSG_UPDATE_AURA_DURATION` instead.
timers.record_spell_go(&[SELF_GUID], REND, Some(SELF_GUID), Some(&spells), 100.0);
assert!(timers.by_aura.is_empty());
let clock = AuraClock {
timers: &timers,
bevy_now: 100.0,
script_now: 100.0,
};
let list = other_unit_auras(&one_debuff(RUPTURE), RIDGE_STALKER, None, true, clock);
assert_eq!((list[0].duration, list[0].expiration_time), (0.0, 0.0));
}
/// A stamp that has run out is the descriptor sitting a moment behind the server, not a timer:
/// reporting it would hand pfUI a negative remainder to draw.
#[test]
fn a_run_out_stamp_reports_no_timer_rather_than_a_negative_one() {
let spells = timer_spells();
let mut timers = OtherAuraDurations::default();
timers.record_spell_go(
&[RIDGE_STALKER],
REND,
Some(SELF_GUID),
Some(&spells),
100.0,
);
let clock = AuraClock {
timers: &timers,
bevy_now: 110.0,
script_now: 110.0,
};
let list = other_unit_auras(&one_debuff(REND), RIDGE_STALKER, None, true, clock);
assert_eq!((list[0].duration, list[0].expiration_time), (0.0, 0.0));
}
/// An aura that was already on a unit when we first saw it has no GO behind it, so it keeps
/// the reference's own answer: no timer. The join is driven by the descriptor, so a stamp for
/// a unit's *other* spell never leaks onto it either.
#[test]
fn an_aura_no_go_of_ours_applied_has_no_timer() {
let spells = timer_spells();
let mut timers = OtherAuraDurations::default();
timers.record_spell_go(
&[RIDGE_STALKER],
REND,
Some(SELF_GUID),
Some(&spells),
100.0,
);
let clock = AuraClock {
timers: &timers,
bevy_now: 101.0,
script_now: 101.0,
};
let other_mob = other_unit_auras(&one_debuff(REND), RIDGE_STALKER + 1, None, true, clock);
assert_eq!(
(other_mob[0].duration, other_mob[0].expiration_time),
(0.0, 0.0)
);
let other_spell = other_unit_auras(&one_debuff(RUPTURE), RIDGE_STALKER, None, true, clock);
assert_eq!(
(other_spell[0].duration, other_spell[0].expiration_time),
(0.0, 0.0)
);
}
}