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:
@@ -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,
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user