ui_aura: another unit's aura durations, learned from the GO that applies them

The target frame's debuff icons had no countdown. libdebuff was tracking fine —
the probe run said `libdebuff guids: 2` after a fight, and the AURA_CAST_ON_OTHER
events carried the right lengths (spell 772 Rend, dur 9000) — because the timers
pfUI-for-ClassicAPI draws do not come through libdebuff at all. Its unit frames
read duration/expirationTime straight off C_UnitAuras.UnitAuraBySlot
(api/unitframes.lua:1680,1788), and libdebuff's own header says the plumbing
"now runs on ClassicAPI's C_UnitAuras (which provides sourceUnit/sourceGUID and
non-player expirationTime)". So the number came from us, and we answered 0 for
every unit but the player.

There is no packet to answer from: 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. Hence every vanilla duration addon
keeping its own table. This is that table, kept client-side instead, because
this client *is* the client: SMSG_SPELL_GO names the spell and its hit list, so
the apply instant is the packet's own arrival rather than a combat-log line
somebody has to parse, and every reader of the aura lists gets the timer at once
instead of each inventing a tracker.

Keyed by (guid, spell) so a re-cast refreshes in place and a recycled slot
cannot inherit a stale stamp. Length comes from the same
ui_cast_events::aura_duration_ms behind nampower's arg8 and GetSpellDuration, so
the three cannot disagree, and its 0 — permanent, no SpellDuration.dbc row, or a
combo-point/level-scaled one — stamps nothing: a timerless icon beats one
counting to the wrong moment. The join is driven by the unit's own
UNIT_FIELD_AURA, so a stamp for an aura the target resisted is never read, and a
run-out stamp reports nothing rather than a negative remainder.

Not covered, by construction: an aura already on a unit before we streamed it,
and one applied out of our range. No GO, no timer — which is what the mods this
stands in for also do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-17 07:56:35 -06:00
co-authored by Claude Opus 5
parent 608b4b961b
commit 80648267d5
4 changed files with 421 additions and 18 deletions
+21 -1
View File
@@ -69,6 +69,10 @@ pub(crate) struct Lifecycle<'w> {
auto_repeat: ResMut<'w, AutoRepeatActive>,
channel: ResMut<'w, ActiveChannel>,
aura_durations: ResMut<'w, AuraDurations>,
/// Every other unit's aura durations, learned from the GO that applies them: 1.12 sends
/// `SMSG_UPDATE_AURA_DURATION` for the player alone, so a target's debuff timer has no packet
/// of its own behind it (`crate::ui_aura::OtherAuraDurations`).
other_aura_durations: ResMut<'w, crate::ui_aura::OtherAuraDurations>,
spell_mods: ResMut<'w, crate::spell::SpellModifiers>,
pet_bar: ResMut<'w, crate::ui_pet::PetBar>,
/// The PlayAnimation call-order counter: every animation-bearing message stamps `next()`,
@@ -274,7 +278,12 @@ fn on_spell_start(In(ev): In<SessionEvent>, mut l: Lifecycle, mut sc: Scene) {
}
}
fn on_spell_go(In(ev): In<SessionEvent>, mut l: Lifecycle, mut sc: Scene) {
fn on_spell_go(
In(ev): In<SessionEvent>,
mut l: Lifecycle,
mut sc: Scene,
real_clock: Res<Time<Real>>,
) {
if let SessionEvent::SpellGo {
caster,
spell_id,
@@ -292,6 +301,17 @@ fn on_spell_go(In(ev): In<SessionEvent>, mut l: Lifecycle, mut sc: Scene) {
// nampower v2's `AURA_CAST_ON_SELF`/`_OTHER`, queued before `spell_go` consumes the hit
// list. Pushed, not fired: these handlers hold no `UiScript`. Nothing below writes this
// queue, so the position is about borrowing `hits`, not about ordering.
// The target-side duration stamps, before the hit list is consumed and for the same
// reason the queue above is pushed here: `Spell.dbc` is already in hand. Ungated by
// `NP_EnableAuraCastEvents` — that switch is about firing nampower's events, where these
// stamps are this client's own aura state and feed its own frames.
l.other_aura_durations.record_spell_go(
&hits,
spell_id,
l.self_guid.0,
l.spells.as_deref(),
real_clock.elapsed_secs_f64(),
);
l.aura_casts_lua.push_spell_go(
caster,
&hits,
+391 -14
View File
@@ -1,10 +1,13 @@
//! 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 the player's durations.
//! 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;
@@ -58,6 +61,101 @@ impl AuraDurations {
}
}
/// 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,
@@ -82,6 +180,15 @@ impl PlayerAuraCache {
}
}
/// 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)]
@@ -93,6 +200,7 @@ 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
@@ -431,12 +539,18 @@ fn projection_of(list: &[AuraState]) -> Vec<AuraProjection> {
}
/// Another unit's auras as `UnitBuff 0x519500` and `UnitDebuff 0x5198f0` read them: ascending
/// slot within each half, no durations, through the display filter. `buffs_visible` false drops
/// the helpful half, the unit-level gate's nil at every index ([`buffs_visible_on`]).
/// 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
@@ -445,6 +559,10 @@ fn other_unit_auras(
.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()),
@@ -454,9 +572,8 @@ fn other_unit_auras(
.zip(catalog)
.and_then(|(d, cat)| cat.dispel_name(d))
.map(str::to_string),
// The 1.12 wire carries durations for the player alone.
duration: 0.0,
expiration_time: 0.0,
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`).
@@ -569,8 +686,7 @@ fn feed_auras(
// The `UnitBuff` gate's inputs, the same reaction the selection ring and nameplates use.
factions: Option<Res<Factions>>,
reputations: Res<Reputations>,
// Read-only: the net path writes the stamps and only the session's end drops them.
durations: Res<AuraDurations>,
stamps: AuraStamps,
mut cache: ResMut<PlayerAuraCache>,
time: Res<Time<Real>>,
mut mem: ResMut<AuraFeedMemory>,
@@ -615,7 +731,7 @@ fn feed_auras(
.map(|c| {
let display = catalog.and_then(|cat| cat.get(c.spell_id));
let (duration, expiration_time) = join_duration(
durations.by_slot.get(&c.slot),
stamps.player.by_slot.get(&c.slot),
c.appeared_at,
bevy_now,
script_now,
@@ -648,6 +764,14 @@ fn feed_auras(
})
.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);
@@ -713,7 +837,7 @@ fn feed_auras(
.cloned()
},
);
Some(other_unit_auras(target_store, catalog, buffs))
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`).
@@ -725,7 +849,7 @@ fn feed_auras(
// 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, catalog, true));
.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
@@ -749,7 +873,7 @@ fn feed_auras(
.cloned()
},
);
Some(other_unit_auras(unit_store, catalog, buffs))
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 {
@@ -1007,6 +1131,7 @@ fn join_duration(
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>,
) {
@@ -1020,6 +1145,8 @@ fn end_session_aura_state(
}
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
@@ -1291,6 +1418,11 @@ mod tests {
/// `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(&[
@@ -1307,7 +1439,14 @@ mod tests {
fn the_unit_gate_drops_the_whole_helpful_half_and_never_the_harmful_one() {
let store = ridge_stalker_slots(0);
let denied = other_unit_auras(&store, None, false);
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],
@@ -1315,7 +1454,7 @@ mod tests {
);
assert!(denied.iter().all(|a| !a.helpful));
let allowed = other_unit_auras(&store, None, true);
let allowed = other_unit_auras(&store, RIDGE_STALKER, None, true, clock);
assert_eq!(
allowed.iter().map(|a| a.spell_id).collect::<Vec<_>>(),
[5916, 22766],
@@ -1898,4 +2037,242 @@ end)
"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)
);
}
}
+8 -3
View File
@@ -8,8 +8,12 @@
//! ([`player_buff`]) takes a 0-based index and returns a cache position, the same number under
//! every filter, which its siblings and `GameTooltip:SetPlayerBuff` take in place of the counter.
//!
//! `source` is always nil, and only the player's auras carry a duration: the 1.12 wire has no aura
//! caster and sends durations for the player alone, so the stock target frame shows no timers.
//! `source` is always nil. The player's durations come off the wire
//! (`SMSG_UPDATE_AURA_DURATION`); every other unit's are learned app-side from the
//! `SMSG_SPELL_GO` that applied the aura (`benilla::ui_aura`'s `OtherAuraDurations`), because
//! 1.12 sends durations for the player alone — which is why the *stock* target frame shows no
//! timers and why pfUI-for-ClassicAPI's, reading `expirationTime` straight off `C_UnitAuras`,
//! needs them to be filled here.
use mlua::{Lua, MultiValue, Value};
@@ -30,7 +34,8 @@ pub struct AuraState {
pub count: u8,
/// `"Magic"`, `"Curse"`, `"Disease"`, `"Poison"` or `None`; the debuff border tints by it.
pub debuff_type: Option<String>,
/// Seconds, from the last apply or refresh; 0 when permanent or on a unit not the player.
/// Seconds, from the last apply or refresh; 0 when permanent, and on another unit when no
/// cast of ours applied the aura or this client will not state its length.
pub duration: f64,
/// The `GetTime()` instant it runs out; 0 when [`Self::duration`] is 0.
pub expiration_time: f64,
File diff suppressed because one or more lines are too long