From 80648267d572bde609f2e3fdacec4fbc54b53838 Mon Sep 17 00:00:00 2001 From: grom Date: Sat, 17 Oct 2026 07:56:35 -0600 Subject: [PATCH] ui_aura: another unit's aura durations, learned from the GO that applies them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/src/spell/net.rs | 22 +- crates/benilla-app/src/ui_aura.rs | 405 ++++++++++++++++++++++- crates/benilla-ui/src/script/aura/mod.rs | 11 +- docs/ADDON_COMPAT.md | 1 + 4 files changed, 421 insertions(+), 18 deletions(-) diff --git a/crates/benilla-app/src/spell/net.rs b/crates/benilla-app/src/spell/net.rs index e59f442a..bf574462 100644 --- a/crates/benilla-app/src/spell/net.rs +++ b/crates/benilla-app/src/spell/net.rs @@ -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, mut l: Lifecycle, mut sc: Scene) { } } -fn on_spell_go(In(ev): In, mut l: Lifecycle, mut sc: Scene) { +fn on_spell_go( + In(ev): In, + mut l: Lifecycle, + mut sc: Scene, + real_clock: Res>, +) { if let SessionEvent::SpellGo { caster, spell_id, @@ -292,6 +301,17 @@ fn on_spell_go(In(ev): In, 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, diff --git a/crates/benilla-app/src/ui_aura.rs b/crates/benilla-app/src/ui_aura.rs index e94cf86b..3ace46f4 100644 --- a/crates/benilla-app/src/ui_aura.rs +++ b/crates/benilla-app/src/ui_aura.rs @@ -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, + 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::() + .init_resource::() .init_resource::() .init_resource::() // 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 { } /// 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 { 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>, reputations: Res, - // Read-only: the net path writes the stamps and only the session's end drops them. - durations: Res, + stamps: AuraStamps, mut cache: ResMut, time: Res>, mut mem: ResMut, @@ -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>, mut durations: ResMut, + mut other_durations: ResMut, mut cache: ResMut, mut mem: ResMut, ) { @@ -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::>(), [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::>(), [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) + ); + } } diff --git a/crates/benilla-ui/src/script/aura/mod.rs b/crates/benilla-ui/src/script/aura/mod.rs index 66a4a9ad..ef7d6b9f 100644 --- a/crates/benilla-ui/src/script/aura/mod.rs +++ b/crates/benilla-ui/src/script/aura/mod.rs @@ -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, - /// 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, diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 20c40c33..c969cfbb 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -124,6 +124,7 @@ in this file on the strength of the namreeb research. pepo's v2 has its own | `SetOwner: unknown anchor 'BOTTOMRIGHT'` | **unexplained — diagnostic added** | 566 occurrences in one live session, the loudest line in the log by 20×, and the cause is **not yet known**. Ruled out: every `SetOwner` call site in every installed addon `.lua` and `.xml` passes a valid `ANCHOR_*` string (verified with a paren-matching scan, after a naive regex truncated nested calls and gave a false reading); `script/tooltip/verbs.rs` does no prefix-stripping, matching the full uppercased name; and it does **not** reproduce under the addon harness, so it needs real in-world hovering. It is a *warning*, not an error — the anchor falls back to `ANCHOR_LEFT` and the tooltip still draws, just anchored wrongly. The warning now names the **owner frame**, which distinguishes an addon caller from the stock FrameXML off the player's patch chain; the next session log should identify it. Note `"ANCHOR_TOP"` (`pfUI/modules/mapreveal.lua:44`) and `"NONE"` (`modules/buffwatch.lua:117`) are separately invalid in 1.12 — the reference has nine anchor modes and neither is among them — so defaulting those to Left matches the reference's own silent behaviour | | colour getters return a `ColorMixin`, not a tuple | fixed | `C_UnitAuras.GetAuraDispelTypeColor` returned `(r, g, b)`, but pfUI's line is `GetAuraDispelTypeColor(dtype or ""):GetRGBA()` (`api/unitframes.lua:1778`) — upstream's return *is* an object, so a tuple raises rather than merely reading awkwardly. Both it and `C_ClassColor.GetClassColor` now build through the real `CreateColor` (`color_mixin.rs`'s `color_object`), which fetches the global at call time rather than holding a `Function` handle, so install order inside this directory stays irrelevant. `GetClassColor` previously hand-rolled a shell carrying two formatters — enough for the `:WrapTextInColorCode` calls pfUI makes, latent for `:GetRGBA()`. Building a fresh object also settles that shell's own stated objection to a mixin (mutating a shared class colour would edit FrameXML's row): the `r`/`g`/`b` are copies, pinned by test | | State (bare globals) | partial | `compat/classic_api/state.rs` + new per-tick posture plumbing: `SelfMovement` (`script/model.rs`), `UiScript::set_self_movement` (`script/self_movement.rs`), fed by `ui_unit::feed_self_movement` from the mover's movement flags (swim, fall), the descriptor (mount display id, `UNIT_FIELD_BYTES_2` form byte) and the area pass's own indoor down-ray. 1.12's Lua can reach none of these at all, so this is new engine data, not a wrapper. Done: `IsSwimming`, `IsMounted`, `IsIndoors`, `IsOutdoors`, `IsFalling`, `GetShapeshiftFormID`, and `IsInGroup`/`IsInRaid` (read the party/raid roster, the same authority `GetNumPartyMembers`/`GetNumRaidMembers` answer from — not duplicated into the posture snapshot). Not built, each for a stated reason in that file's header: `IsStealthed`/`GetSheathState`/`IsAssistingRitual` (no engine state tracks any of the three), `GetMirrorTimerInfo`/`GetMirrorTimerProgress` (benilla's mirror timers are event-only — `ui_mirror.rs` fires `MIRROR_TIMER_START`/`_PAUSE`/`_STOP` and keeps no queryable snapshot), `Dismount`/`CancelShapeshiftForm` (actions; vanilla cancels a form by re-casting its spell, and the engine knows the form *index*, not the spell behind it). Cost: `UPDATE_ACTIONABLE_CEILING` +3, cited in `game_plugins.rs`. **This same plumbing unblocks SuperWoW's own lowercase-initial `IsSwimming`/`IsMounted`/`isIndoors` siblings** (its "New functions" row above) — a later pass can wire those to `SelfMovement` directly instead of building anything | +| A **non-player** aura's `duration` / `expirationTime` | done | `crates/benilla-app/src/ui_aura.rs`'s `OtherAuraDurations`: a `(unit guid, spell id)` table stamped by `spell/net.rs`'s `on_spell_go`, joined into every other unit's aura list at the feed. **This was the hole behind "no debuff timers on the target frame".** pfUI-for-ClassicAPI reads its unit-frame timers straight off `C_UnitAuras.UnitAuraBySlot`'s `duration`/`expirationTime` (`api/unitframes.lua:1680,1788`) rather than through libdebuff — its own header says the plumbing "now runs on ClassicAPI's C_UnitAuras (which provides sourceUnit/sourceGUID and **non-player expirationTime**)" and libdebuff's `UnitDebuff`/`UnitOwnDebuff` survive only as adapters over it. So libdebuff could be tracking perfectly (it was: `libdebuff_all_auras` held rows) and the icons still draw no sweep, because the number they read came from us and we answered 0. 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. It is learned instead from the GO that applies the aura — which this client sees natively, with the apply instant being the packet's own arrival rather than a combat-log line to parse. 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 row, or a combo-point/level-scaled row — the `todo` row above) stamps nothing, leaving the icon timerless rather than 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 stamp that has run out 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 | | UnitAuras (`C_UnitAuras`) | done | `compat/classic_api/unit_auras.rs`, modern named-table/slot shape over the same `AuraState` list the stock `UnitBuff`/`UnitDebuff`/`GetPlayerBuff` already read (including this session's SuperWoW spell-id extension). `GetAuraDataByIndex`, `GetBuffDataByIndex`, `GetDebuffDataByIndex`, `GetAuraDataBySlot`, `UnitAuraBySlot`, `GetAuraSlots`, `GetUnitAuras`, `GetUnitAuraBySpellID`, `GetPlayerAuraBySpellID`, `GetAuraDataBySpellName`, `GetAuraDispelTypeColor`, and the positional `UnitAura`/`UnitBuff`/`UnitDebuff` siblings. `RegisterComboDuration`/`RegisterAuraDurationModifierByTrigger` accept and discard: they are addon-side registration hooks with no 1.12 data behind them and nothing here would ever read the registration | | Spell (`C_Spell`) | partial | `compat/classic_api/spell.rs`, spell-id-first: `DoesSpellExist`, `GetSpellName`, `GetSpellTexture`, `GetSpellSubtext`, `GetSpellInfo` (named table), `IsSpellPassive`, `IsSpellKnown`, `IsCurrentSpell`, `GetSpellCooldown`, `IsSpellUsable`, `GetSchoolString` (+ the seven `SCHOOL_MASK_*` constants), `CanDualWield`, `CastAtCursor`, `CastAtUnit`, `CancelSpellByID`. **The five display getters answer for any shipped spell**, not just the player's own ~200: `script/spellbook.rs`'s `spell_display` reads the book first (it carries the live icon substitutions) and falls back to the host's `Spell.dbc` probe for every other id — the fix for pfUI's `api/unitframes.lua:42`, which keys a predict table on `strlower(GetSpellTexture(774))` at file scope on every class. The probe carries only name/rank/icon, so the predicates below it (`IsSpellPassive`, `IsSpellKnown`, `IsCurrentSpell`, `GetSpellCooldown`, `IsSpellUsable`) still read the book alone, which is the right source for all five. The rest of the namespace's ~30 functions (`GetSpellDescription`, `GetSpellPowerCost`, `GetSpellReagents`, `GetSpellEffectInfo`, the range/harm/helpful predicates, `UnitCastingInfo`/`UnitChannelInfo`, `GetSpellLevelInfo`, …) are unstarted: each needs a `SpellDisplay` column the probe does not carry, and widening `SpellLookup` field by field is the route, with a reason each time | | Spell — the per-unit cast feed (`C_Spell.UnitCastingInfo`, `UnitChannelInfo`, `CastingInfo`, `ChannelInfo`) | partial — **the feed is built; the `UNIT_SPELLCAST_*` events that drive pfUI's bars are not** | Split out of the `C_Spell` row above rather than folded into it, because this half is a live feed rather than a getter over data already in hand, and because what it does *not* yet reach is worth a row of its own. This closes four of the "ClassicAPI tail" row's 46 measured members — the three it filed under "needs a live feed" for cast state, plus `CastingInfo`, which upstream lists in the same breath (`docs/classic-api-functions.md`, the Spell section) and which is `UnitCastingInfo("player")` exactly. **Built**: `compat/classic_api/spell.rs`'s `cast_info` over `Model::unit_cast`, fed from `benilla-app`'s `ui_cast_events.rs` ([`CastWindows`]), which folds the **same queue of wire edges** that already raises SuperWoW's `UNIT_CASTEVENT` — `SMSG_SPELL_START`, `SMSG_SPELL_GO`, `SMSG_SPELL_FAILED_OTHER`, `MSG_CHANNEL_START`, plus three state-only edges added here (`SMSG_SPELL_DELAYED` pushback, `MSG_CHANNEL_UPDATE`, and `inflight.rs`'s local Esc/move cancel, none of which is one of the wiki's six `UNIT_CASTEVENT` words, so each folds state and fires nothing). One queue, not two: an addon woken by `UNIT_CASTEVENT` polls `C_Spell` for the timing the event does not carry (pfUI, `modules/nameplates.lua:648-656`), and each row's state is pushed to the VM *before* its own event fires so the two cannot contradict each other — `the_event_and_the_window_agree_inside_the_handler_the_event_woke` is that property, and it caught a real one-tick-stale bug on the way in. **Keyed by caster GUID, deliberately not a `UnitState` field**: tokens are a naming layer that moves under units, and a cast carried on `"target"`'s snapshot would hand the next target the old one's half-finished bar. A read folds token -> GUID through `Model::unit`, so every token spelling this client answers (stock, a raw SuperWoW GUID string, `"mark"`, `"owner"`, ClassicAPI's `"focus"`, `nameplate`) reads the right window for free. **Units**: `startMs`/`endMs` are **milliseconds** on `GetTime()`'s clock, stamped at the drain — the same place and frame `ui_cast.rs` stamps the stock bar's `SPELLCAST_START`, so pfUI's bar and `CastingBarFrame` start on the same millisecond. The two verbs are disjoint (a channel answers only `UnitChannelInfo`), and the shapes differ by one field: `castID` exists for a cast and not for a channel, so `spellID` is the 9th return and the 8th respectively — both of pfUI's destructurings (`modules/nameplates.lua:83`/`:86`) are transcribed verbatim as the test, in `script/tests/cast_info.rs`, because a positional shape is only correct relative to the code that reads it. **Not backed, left nil rather than guessed** (each with its reason in `cast_info`'s doc): `isTradeskill` — 1.12 has no tradeskill column in `Spell.dbc` and no wire bit, so pfUI's queued-craft bar merge simply does not engage; `castID` — 1.12 carries no per-cast identifier anywhere; `notInterruptible` — no such flag exists in the client, in `Spell.dbc` or on the wire, and nil reads as false, which is what every 1.12 cast is; return slot 10 of the cast form — upstream puts something there (pfUI skips exactly one slot to reach `delayMs` at 11) and nothing in this tree says what. `text` (slot 2) answers as `name`; the one per-cast string the reference does spell differently is the stock channel bar's `CHANNELING` word, which is an argument of `SPELLCAST_CHANNEL_START` for the local player, not a per-unit field, and no consumer reads slot 2. `delayMs` (slot 11) **is** backed, from the accumulated `SMSG_SPELL_DELAYED` total. **Another unit's channel is not reachable** and is reported as nothing rather than faked: `MSG_CHANNEL_START`/`UPDATE` carry no caster and vmangos sends them to the caster alone, so only the local player's channel has a window. `UNIT_FIELD_CHANNEL_SPELL` (field 144, public) does name *which* spell another unit is channelling, but carries no start and no length, and a bar with an invented timer is worse than no bar — that field plus a `SpellDuration.dbc` column, with the 0 -> id edge as the start and an explicit nothing for a channel already running when the unit streamed in, is the measured route if it is ever wanted. **What this does not finish**: pfUI's cast bars are driven by ClassicAPI's `UNIT_SPELLCAST_*` token events, which benilla fires none of (the row below costs the per-`nameplate` half; the stock `player`/`target`/`focus` half is equally unbuilt). Without them `modules/castbar.lua` stamps a bar only from its `PLAYER_TARGET_CHANGED`/`PLAYER_FOCUS_CHANGED` re-poll (`:340`) — so **a mob already mid-cast when you target it does now show a correct, correctly-timed bar, and one that starts casting while already targeted does not**; the player's own `pfPlayerCastbar` never stamps at all, since `arg1 == "player"` events are its only way in. Nameplates are the same shape: `modules/nameplates.lua:602` seeds `castState[guid]` from `PollCastInfo` at `NAME_PLATE_UNIT_ADDED`, so a plate that spawns on a casting mob draws its bar, while `:655`'s per-cast seed waits on the event. The feed was the blocking half (the row below records that an event with nothing to read behind it "would fire into a handler that learns nothing"); the events are now the whole remainder, and `C_Spell` is no longer what blocks them |