app+ui: the player's own cast state, and SPELL_CAST_EVENT off the commit
`GetCurrentCastingInfo` and `GetCastInfo` are the two most-called
nampower functions in grom's corpus — 25 and 20 call sites outside
`NampowerAPI.lua` — and one of them calls with no existence guard at
all: `local _,_,_,_,_,_,autoattack = GetCurrentCastingInfo()`
(SuperCleveRoidMacros/Core.lua:94), in the frame that keeps clearing the
target while an autoattack is queued. On this client that line raised.
The earlier doc row said the client had "a spell id, a deadline and a
guard bit — no start instant, no channel state, no GCD end". That was
true of `PendingCast` and wrong about the tree: `ui_cast_events.rs`
already folds a cast window per caster off the same packets, with a
start, an end, a channel bit and the accumulated pushback, behind
`C_Spell.UnitCastingInfo`. What was actually missing was the caster's
own machinery — the reference's cast globals, which have no descriptor
field anywhere — and the GCD, which `spell/cooldowns.rs` has run
correctly all along and never published.
So `GetCastInfo` folds two halves: its timing columns come from the
player's own window, its identity and GCD columns from a new per-tick
`SelfCast` snapshot. The feed declares itself after the window drain, so
the two halves can never describe different casts. Added:
- `SelfCast` (benilla-ui) — the inflight spell `0xceca88`, the bar's
spell `0xceac48`, the auto-repeat key `0xceac30`, casting/channeling/
onswing/autoattack, the cast counter, the cast type, the target, and
the GCD's start and end on the GetTime clock.
- `ui_self_cast.rs` (benilla-app) — the push, plus `SPELL_CAST_EVENT`.
- `Cooldowns::gcd` — the running global cooldown as one timer. `info`
still answers the per-category question every cooldown swirl asks;
this answers "when does the GCD end", which has no spell to key on
because its consumers divide it (`API.GetGCDRemainingMs`) or compare
it against seconds (`[gcd:<1]`).
- `PendingCast`'s cast counter, in the one place every class of commit
already passes through.
Three decisions where the tempting answer was the wrong one:
- `GetCastInfo` is **nil** when nothing is casting, and an instant's GCD
does not count. Answering a table whenever the GCD is up would make
the consumer's fast path always hit — and `API.IsCasting()` *is*
`GetCastInfo() ~= nil`, so `[casting]` would read true for a second
and a half after every instant. The wrapper's own comment says what to
do instead ("GetSpellIdCooldown … works for instant casts").
- `castId` is two different numbers under one upstream name: a counter
in `GetCastInfo`'s table, beside a separate `spellId`; a **spell id**
in `GetCurrentCastingInfo`'s returns, which is how both of its readers
treat it (Init.lua:89 stores it as castingSpellId, Core.lua:5427 hands
it to C_Spell.GetSpellName). Each is implemented as what its own
consumer reads, and both are spelled out at the binding.
- `SPELL_CAST_EVENT` fires for commits, not refusals. Upstream's
`success` can be 0; this client's local refusals funnel through
`CastErrors`, which carries a spell id and a reason byte and neither a
cast type nor a target — a row from there would invent two of five
fields. Nothing in the corpus reads the failing branch
(Core.lua:5177 is one `if success == 1 then` with no else). Written
down at the type rather than left to be found.
Tests run the consumers' own bodies transcribed, as `cast_info.rs` next
door does: `API.GetCastInfo` with its `GetCurrentCastingInfo` fallback
arm, `API.IsCasting`, `API.GetGCDRemainingMs`, `API.GetCastRemainingMs`
and `CleveRoids.UpdateCastingState`. A positional seven-return shape and
a ten-key table are only correct relative to the code that reads them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -79,6 +79,7 @@ use crate::ui_quest_log::UiQuestLogPlugin;
|
||||
use crate::ui_quest_share::QuestSharePlugin;
|
||||
use crate::ui_saved::UiSavedPlugin;
|
||||
use crate::ui_script::UiScriptPlugin;
|
||||
use crate::ui_self_cast::UiSelfCastPlugin;
|
||||
use crate::ui_shapeshift::UiShapeshiftPlugin;
|
||||
use crate::ui_social::UiSocialPlugin;
|
||||
use crate::ui_spellbook::UiSpellbookPlugin;
|
||||
@@ -260,6 +261,7 @@ impl PluginGroup for GamePlugins {
|
||||
.add(UiCastPlugin)
|
||||
.add(UiMirrorPlugin)
|
||||
.add(SuperWowEventsPlugin)
|
||||
.add(UiSelfCastPlugin)
|
||||
.add(crate::combat_text::CombatTextPlugin)
|
||||
.add(crate::nameplates::NameplatesPlugin)
|
||||
.add(crate::raid_marks::RaidMarksPlugin)
|
||||
|
||||
@@ -189,6 +189,7 @@ mod ui_quest_share;
|
||||
mod ui_reputation;
|
||||
mod ui_saved;
|
||||
pub mod ui_script;
|
||||
mod ui_self_cast;
|
||||
mod ui_session;
|
||||
mod ui_shapeshift;
|
||||
mod ui_social;
|
||||
|
||||
@@ -86,6 +86,10 @@ pub(crate) struct CastLadder<'w, 's> {
|
||||
pub(crate) auto_repeat: ResMut<'w, AutoRepeatActive>,
|
||||
pub(crate) trade_skill_opens: ResMut<'w, crate::ui_tradeskill::TradeSkillOpens>,
|
||||
pub(crate) ground: ResMut<'w, super::targeting::SpellTargeting>,
|
||||
/// nampower's `SPELL_CAST_EVENT` rows and the live cast's own columns
|
||||
/// (`crate::ui_self_cast`): every commit below records one, which is also where
|
||||
/// `GetCastInfo` learns a cast's type and target.
|
||||
pub(crate) self_casts: ResMut<'w, crate::ui_self_cast::SelfCastSends>,
|
||||
}
|
||||
|
||||
/// What a targeting-cursor click bound: what `BindLocation 0x6e60f0` or `BindTarget 0x6e5b40`
|
||||
@@ -179,9 +183,32 @@ impl CastLadder<'_, '_> {
|
||||
.unwrap_or(0);
|
||||
self.pending.arm(spell_id, now, cast_time_ms, guards);
|
||||
}
|
||||
if let Some(d) = self.spells.as_ref().and_then(|s| s.catalog.get(spell_id)) {
|
||||
let def = self.spells.as_ref().and_then(|s| s.catalog.get(spell_id));
|
||||
if let Some(d) = def {
|
||||
self.cooldowns.start_gcd(spell_id, d, now);
|
||||
}
|
||||
// nampower's `SPELL_CAST_EVENT`: this is the targeting arm, so the type is `TARGETING`
|
||||
// (or its non-GCD sibling), the one pair of its six values the plain send path never
|
||||
// produces. The target is whatever the click bound, which for a ground or source click is
|
||||
// a point and so no unit at all.
|
||||
self.self_casts.push(crate::ui_self_cast::SelfCastSend {
|
||||
spell_id,
|
||||
cast_type: if def.is_some_and(|d| d.start_recovery_ms == 0) {
|
||||
crate::ui_self_cast::cast_type::TARGETING_NON_GCD
|
||||
} else {
|
||||
crate::ui_self_cast::cast_type::TARGETING
|
||||
},
|
||||
target: match bound {
|
||||
TargetedBind::Unit(guid) => guid,
|
||||
TargetedBind::Item(guid) | TargetedBind::Object(guid) => guid,
|
||||
TargetedBind::Corpse(guid, _) => guid,
|
||||
TargetedBind::Dest(_) | TargetedBind::Source(_) => 0,
|
||||
},
|
||||
item_id: match commit {
|
||||
CastCommit::Item { entry, .. } => entry,
|
||||
CastCommit::Spell => 0,
|
||||
},
|
||||
});
|
||||
self.ground.clear();
|
||||
}
|
||||
|
||||
@@ -245,6 +272,7 @@ impl CastLadder<'_, '_> {
|
||||
&mut self.auto_repeat,
|
||||
&mut self.trade_skill_opens,
|
||||
&mut self.ground,
|
||||
&mut self.self_casts,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -297,6 +325,7 @@ fn send_spell_cast(
|
||||
auto_repeat: &mut AutoRepeatActive,
|
||||
trade_skill_opens: &mut crate::ui_tradeskill::TradeSkillOpens,
|
||||
ground: &mut super::targeting::SpellTargeting,
|
||||
self_casts: &mut crate::ui_self_cast::SelfCastSends,
|
||||
) {
|
||||
let now = Instant::now();
|
||||
let def = spells.and_then(|s| s.catalog.get(spell_id));
|
||||
@@ -662,6 +691,33 @@ fn send_spell_cast(
|
||||
// recorded, so the `modalNextSpell` test (`0x6e7408`) can match a ranged shot; only
|
||||
// `normal_cast` and the item arm guard. The item arm's deadline is shorter because vmangos
|
||||
// answers some `CMSG_USE_ITEM` legs with `SMSG_INVENTORY_CHANGE_FAILURE` and no cast result.
|
||||
// nampower's `SPELL_CAST_EVENT`, which upstream fires at the send and pfUI's libdebuff
|
||||
// relies on arriving *before* `SPELLCAST_START` (`libdebuff.lua:197`): this is the send, and
|
||||
// the stock bar's START is pushed by the packet's echo a round trip later, so that order
|
||||
// holds here for the same reason it does there.
|
||||
//
|
||||
// The type is decided here and reported for the cast's life, as upstream decides it at its
|
||||
// own send. A channel is not one of the cases: whether a cast channels is something
|
||||
// `MSG_CHANNEL_START` tells us a round trip later, and the feed applies `CHANNEL` from the
|
||||
// live window instead.
|
||||
self_casts.push(crate::ui_self_cast::SelfCastSend {
|
||||
spell_id,
|
||||
cast_type: if on_next_swing {
|
||||
crate::ui_self_cast::cast_type::ON_SWING
|
||||
} else if def.is_some_and(|d| d.start_recovery_ms == 0) {
|
||||
crate::ui_self_cast::cast_type::NON_GCD
|
||||
} else {
|
||||
crate::ui_self_cast::cast_type::NORMAL
|
||||
},
|
||||
// The bound wire target, else the selection — upstream's own default since the
|
||||
// `SpellCastEventDefaultTarget` change, which this client's reported version is past.
|
||||
target: target
|
||||
.or(item_target)
|
||||
.or(explicit_object)
|
||||
.or(ctx.selection_guid)
|
||||
.unwrap_or(0),
|
||||
item_id: queried_item,
|
||||
});
|
||||
if commit.is_item() {
|
||||
pending.arm_item(spell_id, now);
|
||||
} else {
|
||||
@@ -768,6 +824,7 @@ mod tests {
|
||||
world.init_resource::<CastErrors>();
|
||||
world.init_resource::<AutoRepeatActive>();
|
||||
world.init_resource::<crate::ui_tradeskill::TradeSkillOpens>();
|
||||
world.init_resource::<crate::ui_self_cast::SelfCastSends>();
|
||||
world.init_resource::<crate::spell::SpellTargeting>();
|
||||
// A bare World has no message storage until it is asked for.
|
||||
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
|
||||
|
||||
@@ -76,11 +76,7 @@ impl CooldownInfo {
|
||||
/// derives the same start every frame; a fresh `Instant::now()` here would jitter it.
|
||||
pub(crate) fn ui_triple(&self, anchor: Instant, ui_now: f64) -> Option<(i64, u32, bool)> {
|
||||
(self.remaining_ms > 0).then(|| {
|
||||
// Signed both ways: a timer armed after the anchor sample projects forward.
|
||||
let start = match self.start.checked_duration_since(anchor) {
|
||||
Some(ahead) => ui_now + ahead.as_secs_f64(),
|
||||
None => ui_now - anchor.duration_since(self.start).as_secs_f64(),
|
||||
};
|
||||
let start = ui_stamp(self.start, anchor, ui_now);
|
||||
#[allow(clippy::cast_possible_truncation)] // session-clock ms fit i64
|
||||
(
|
||||
(start * 1000.0).round() as i64,
|
||||
@@ -91,6 +87,20 @@ impl CooldownInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// One `Instant` on the VM's `GetTime()` clock, given the frame's one clock pair
|
||||
/// ([`crate::ui_script::UiClock`]). Signed both ways: a timer armed *after* the anchor sample
|
||||
/// projects forward rather than clamping to it, which is what keeps a cooldown stamped mid-frame
|
||||
/// from reading as if it started at the frame's beginning.
|
||||
///
|
||||
/// The one place this arithmetic lives, so every cooldown and GCD a feed publishes derives the
|
||||
/// same start from the same pair. A fresh `Instant::now()` in any of them would jitter it.
|
||||
pub(crate) fn ui_stamp(at: Instant, anchor: Instant, ui_now: f64) -> f64 {
|
||||
match at.checked_duration_since(anchor) {
|
||||
Some(ahead) => ui_now + ahead.as_secs_f64(),
|
||||
None => ui_now - anchor.duration_since(at).as_secs_f64(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's cooldown list (the client's `SpellHistory` at `0xcecaec`); the pet list has no
|
||||
/// consumer.
|
||||
#[derive(Resource, Default)]
|
||||
@@ -503,6 +513,32 @@ impl Cooldowns {
|
||||
self.info(spell_id, item_entry, spell, now).remaining_ms > 0
|
||||
}
|
||||
|
||||
/// The running global cooldown as one timer — the record whose GCD leg has the most left,
|
||||
/// with its start and duration. `None` when nothing is on the GCD.
|
||||
///
|
||||
/// **One number for what the reference keys per `startRecoveryCategory`,** and that is a
|
||||
/// deliberate narrowing rather than an oversight. [`Self::info`] answers the GCD *for a
|
||||
/// spell*, matching the node's category against that spell's, which is the question every
|
||||
/// cooldown swirl asks. nampower's `GetCastInfo().gcdEndS` asks a different one — "when does
|
||||
/// the global cooldown end" — and its consumers divide the answer
|
||||
/// (`NampowerAPI.lua`'s `API.GetGCDRemainingMs`) or compare it against a number of seconds
|
||||
/// (`SuperCleveRoidMacros`' `[gcd:<1]` conditional). Neither has a spell to key on. The
|
||||
/// longest running leg is the one a player is actually waiting on, and in 1.12 practice it is
|
||||
/// category 133's: every GCD-bearing spell in the game shares it bar a handful.
|
||||
///
|
||||
/// On-hold records are included, as [`Self::info`]'s GCD leg includes them — that leg ignores
|
||||
/// `on_hold` on purpose (`0x6e15cc` has no such test), and the GCD of a parked record is
|
||||
/// still running.
|
||||
pub(crate) fn gcd(&self, now: Instant) -> Option<(Instant, Duration)> {
|
||||
self.records
|
||||
.iter()
|
||||
.filter(|r| !r.gcd.duration.is_zero())
|
||||
.map(|r| (r.gcd, r.gcd.remaining(now)))
|
||||
.filter(|(_, left)| !left.is_zero())
|
||||
.max_by_key(|(_, left)| *left)
|
||||
.map(|(t, _)| (t.start, t.duration))
|
||||
}
|
||||
|
||||
/// `GetCooldownInfo 0x6e13e0`: the longest remainder over every record's three legs.
|
||||
///
|
||||
/// - `Effect[0]` of ATTACK or TRADE_SKILL always reads cold (`0x6e1439`).
|
||||
@@ -599,6 +635,44 @@ mod tests {
|
||||
spell(44, 0, 15_000, (0, 0), 0)
|
||||
}
|
||||
|
||||
/// [`Cooldowns::gcd`]: the running global cooldown as one timer, for nampower's
|
||||
/// `GetCastInfo().gcdEndS`. The longest leg wins, a lapsed one is gone, and a record with no
|
||||
/// GCD pair never contributes — Charge's 15 s category cooldown must not read as a GCD.
|
||||
#[test]
|
||||
fn the_gcd_reader_answers_the_longest_running_leg() {
|
||||
let t0 = Instant::now();
|
||||
let mut cds = Cooldowns::default();
|
||||
assert_eq!(cds.gcd(t0), None, "nothing armed");
|
||||
|
||||
// Charge: a 15 s category cooldown and NO GCD pair. Not a GCD, however long it runs.
|
||||
cds.start_spell(100, &charge(), 0, t0);
|
||||
assert_eq!(
|
||||
cds.gcd(t0),
|
||||
None,
|
||||
"a category cooldown is not the global cooldown"
|
||||
);
|
||||
|
||||
cds.start_gcd(133, &fireball(), t0);
|
||||
assert_eq!(cds.gcd(t0), Some((t0, Duration::from_millis(1_500))));
|
||||
// Half way through it is still the answer; past its end there is none, with no sweep
|
||||
// needed — the reader filters on the remainder, as every other read here does.
|
||||
assert_eq!(
|
||||
cds.gcd(t0 + Duration::from_millis(700)),
|
||||
Some((t0, Duration::from_millis(1_500)))
|
||||
);
|
||||
assert_eq!(cds.gcd(t0 + Duration::from_millis(1_600)), None);
|
||||
|
||||
// A second, later GCD: the one with the most left wins, which is the one a player is
|
||||
// waiting on.
|
||||
let t1 = t0 + Duration::from_millis(800);
|
||||
cds.start_gcd(772, &fireball(), t1);
|
||||
assert_eq!(
|
||||
cds.gcd(t1),
|
||||
Some((t1, Duration::from_millis(1_500))),
|
||||
"the fresher leg has 1500 ms left against the older one's 700"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gcd_spreads_to_every_spell_sharing_the_start_recovery_category() {
|
||||
let t0 = Instant::now();
|
||||
|
||||
@@ -32,7 +32,19 @@ const CAST_SLACK: Duration = Duration::from_secs(2);
|
||||
/// `SMSG_SPELL_FAILED_OTHER`), keyed on the spell id, or by [`local_self_cancel`]; `deadline`
|
||||
/// covers a lost resolution.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PendingCast(Option<PendingCastState>);
|
||||
pub(crate) struct PendingCast {
|
||||
state: Option<PendingCastState>,
|
||||
/// nampower's `GetCastInfo().castId`: a monotonic count of committed casts, which that table
|
||||
/// carries **beside** `spellId` and so is not one. The reference has no such number — it is
|
||||
/// nampower's own, minted per cast it starts — so the honest stand-in is the one counter every
|
||||
/// commit already passes through: both arms below bump it, and nothing else writes it.
|
||||
///
|
||||
/// Counts ranged shots and item uses too, because [`Self::arm`] and [`Self::arm_item`] are
|
||||
/// where every class of commit lands and nampower likewise counts every cast it starts. Wraps
|
||||
/// rather than saturating: a consumer compares two of these for equality ("is this the same
|
||||
/// cast I saw"), never their difference, and a saturated counter would answer *equal* forever.
|
||||
cast_seq: u32,
|
||||
}
|
||||
|
||||
struct PendingCastState {
|
||||
spell_id: u32,
|
||||
@@ -47,7 +59,7 @@ struct PendingCastState {
|
||||
impl PendingCast {
|
||||
/// A guarding cast is outstanding, inside its deadline.
|
||||
pub(crate) fn in_flight(&self, now: Instant) -> bool {
|
||||
self.0
|
||||
self.state
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.guards && now < p.deadline)
|
||||
}
|
||||
@@ -55,7 +67,7 @@ impl PendingCast {
|
||||
/// The reference's `0xceca88`: the last committed, unresolved cast of any class, which
|
||||
/// `HandleCastResult 0x6e7330` tests at `0x6e7408` before reading `modalNextSpell`.
|
||||
pub(crate) fn committed(&self, now: Instant) -> Option<u32> {
|
||||
self.0
|
||||
self.state
|
||||
.as_ref()
|
||||
.filter(|p| now < p.deadline)
|
||||
.map(|p| p.spell_id)
|
||||
@@ -63,7 +75,7 @@ impl PendingCast {
|
||||
|
||||
/// The guarding cast's spell id, which `IsCurrentAction`'s checked state keys on.
|
||||
pub(crate) fn current(&self, now: Instant) -> Option<u32> {
|
||||
self.0
|
||||
self.state
|
||||
.as_ref()
|
||||
.filter(|p| p.guards && now < p.deadline)
|
||||
.map(|p| p.spell_id)
|
||||
@@ -80,7 +92,8 @@ impl PendingCast {
|
||||
/// `delay` (pushback) still extends past this, exactly as nampower's own delayed-cast hook
|
||||
/// does.
|
||||
pub(crate) fn arm(&mut self, spell_id: u32, now: Instant, cast_time_ms: u32, guards: bool) {
|
||||
self.0 = Some(PendingCastState {
|
||||
self.cast_seq = self.cast_seq.wrapping_add(1);
|
||||
self.state = Some(PendingCastState {
|
||||
spell_id,
|
||||
deadline: now + Duration::from_millis(u64::from(cast_time_ms)) + CAST_SLACK,
|
||||
guards,
|
||||
@@ -91,7 +104,8 @@ impl PendingCast {
|
||||
/// via `0x6e5a90`, so it writes the same inflight id and meets the same gate. Only the
|
||||
/// deadline differs.
|
||||
pub(crate) fn arm_item(&mut self, spell_id: u32, now: Instant) {
|
||||
self.0 = Some(PendingCastState {
|
||||
self.cast_seq = self.cast_seq.wrapping_add(1);
|
||||
self.state = Some(PendingCastState {
|
||||
spell_id,
|
||||
deadline: now + ITEM_SEND_PROVISIONAL,
|
||||
guards: true,
|
||||
@@ -101,15 +115,20 @@ impl PendingCast {
|
||||
/// `SMSG_SPELL_DELAYED`: extend from the later of the deadline and now, so a lapsed deadline
|
||||
/// re-arms.
|
||||
pub(crate) fn delay(&mut self, delay_ms: u32, now: Instant) {
|
||||
if let Some(p) = &mut self.0 {
|
||||
if let Some(p) = &mut self.state {
|
||||
p.deadline = p.deadline.max(now) + Duration::from_millis(u64::from(delay_ms));
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::cast_seq`]'s reader, for the self-cast feed.
|
||||
pub(crate) fn cast_seq(&self) -> u32 {
|
||||
self.cast_seq
|
||||
}
|
||||
|
||||
/// Clear on a resolution for our spell only: a proc's `SMSG_SPELL_GO` mid-cast must not.
|
||||
pub(crate) fn clear_if(&mut self, spell_id: u32) {
|
||||
if self.0.as_ref().is_some_and(|p| p.spell_id == spell_id) {
|
||||
self.0 = None;
|
||||
if self.state.as_ref().is_some_and(|p| p.spell_id == spell_id) {
|
||||
self.state = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,6 +512,7 @@ mod tests {
|
||||
world.init_resource::<crate::ui_action::CastErrors>();
|
||||
world.init_resource::<crate::spell::AutoRepeatActive>();
|
||||
world.init_resource::<crate::ui_tradeskill::TradeSkillOpens>();
|
||||
world.init_resource::<crate::ui_self_cast::SelfCastSends>();
|
||||
world.init_resource::<SpellTargeting>();
|
||||
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
|
||||
world.init_resource::<crate::ui_party::GroupState>();
|
||||
|
||||
@@ -148,6 +148,7 @@ mod tests {
|
||||
world.init_resource::<crate::ui_action::UiErrorKeys>();
|
||||
world.init_resource::<crate::spell::AutoRepeatActive>();
|
||||
world.init_resource::<crate::ui_tradeskill::TradeSkillOpens>();
|
||||
world.init_resource::<crate::ui_self_cast::SelfCastSends>();
|
||||
world.init_resource::<super::super::SpellTargeting>();
|
||||
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
|
||||
world.init_resource::<Messages<WorldClick>>();
|
||||
|
||||
@@ -581,7 +581,7 @@ impl CastWindows {
|
||||
/// `OnEvent` reads `GetTime()` when `SPELLCAST_START` reaches it, and that event is fired by
|
||||
/// `ui_cast.rs`'s drain in this same set, off the same frame's clock. The two bars therefore start
|
||||
/// on the same millisecond instead of one packet-time apart.
|
||||
fn drain_cast_edges(
|
||||
pub(crate) fn drain_cast_edges(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut feed: ResMut<CastEventFeed>,
|
||||
mut windows: ResMut<CastWindows>,
|
||||
|
||||
@@ -750,6 +750,7 @@ mod tests {
|
||||
.init_resource::<crate::net::Reputations>()
|
||||
.init_resource::<crate::player::Player>()
|
||||
.init_resource::<crate::spell::PendingCast>()
|
||||
.init_resource::<crate::ui_self_cast::SelfCastSends>()
|
||||
.init_resource::<crate::spell::QueuedMeleeSpell>()
|
||||
.init_resource::<crate::spell::Cooldowns>()
|
||||
.init_resource::<crate::spell::SpellModifiers>()
|
||||
@@ -1705,6 +1706,7 @@ mod bind_confirm_tests {
|
||||
.init_resource::<crate::net::Reputations>()
|
||||
.init_resource::<crate::player::Player>()
|
||||
.init_resource::<crate::spell::PendingCast>()
|
||||
.init_resource::<crate::ui_self_cast::SelfCastSends>()
|
||||
.init_resource::<crate::spell::QueuedMeleeSpell>()
|
||||
.init_resource::<crate::spell::Cooldowns>()
|
||||
.init_resource::<crate::spell::SpellModifiers>()
|
||||
|
||||
@@ -2663,6 +2663,18 @@ mod tests {
|
||||
"DEBUFF_REMOVED_SELF",
|
||||
"nampower: the removal half of the pair above",
|
||||
),
|
||||
(
|
||||
"SPELL_CAST_EVENT",
|
||||
"nampower: the player's own cast as the client committed it, carrying the spell \
|
||||
id, the cast type, the target and the item — fired at the send, which is \
|
||||
before the server's echo raises SPELLCAST_START, and that order is what \
|
||||
pfUI's libdebuff relies on (libdebuff.lua:197). 1.12's own SPELLCAST_START is \
|
||||
argless but for a label and a duration: it names no spell id and no target, so \
|
||||
nothing on that surface can correlate a cast with what it was cast at. \
|
||||
SuperCleveRoidMacros registers this one gated on GetCurrentCastingInfo \
|
||||
existing (Core.lua:4899), so the function and the event have to arrive \
|
||||
together or the addon waits for an event that never comes",
|
||||
),
|
||||
(
|
||||
"BAG_NEW_ITEMS_UPDATED",
|
||||
"ClassicAPI's new-item marker, fired from its own callback registry",
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
//! The player's own cast machinery, pushed to the VM once per tick, and nampower's
|
||||
//! `SPELL_CAST_EVENT` fired off the same commit.
|
||||
//!
|
||||
//! **What this is for.** `GetCurrentCastingInfo` and `GetCastInfo` are the two most-called
|
||||
//! nampower functions in grom's addon corpus (25 and 20 call sites outside the API wrapper), and
|
||||
//! between them they want five spell ids, four flags, a cast type, a target and the global
|
||||
//! cooldown's end. None of that is descriptor data: it is the reference's own cast globals — the
|
||||
//! inflight slot `0xceca88`, the cast with the bar `0xceac48`, the auto-repeat key `0xceac30`,
|
||||
//! the queued strike, the melee attack lock — which live in `spell/inflight.rs`'s resources, plus
|
||||
//! the GCD `spell/cooldowns.rs` already runs and never published.
|
||||
//!
|
||||
//! **Why the two verbs are built together with the event.** `SuperCleveRoidMacros` registers
|
||||
//! `SPELL_CAST_EVENT` gated on `GetCurrentCastingInfo` existing (`Core.lua:4899`), so defining the
|
||||
//! function alone would move that addon from "never registered" to "registered and silent" — a
|
||||
//! worse state, because its channel tracking then waits for an event that never comes.
|
||||
//!
|
||||
//! **Why a snapshot and not a probe.** Same rule as every other verb here: a cast window is a
|
||||
//! per-frame fact, and the VM must answer out of a feed pushed in the frame it answers in
|
||||
//! (`docs/ADDON_COMPAT.md`, and the `vm-feed-must-be-same-frame` lesson that cost a day on the
|
||||
//! minimap). [`feed_self_cast`] therefore declares itself after the cast-window drain, so
|
||||
//! `GetCastInfo`'s timing columns and its identity columns can never describe different casts.
|
||||
//!
|
||||
//! **Why the `ui_` name**: `tests/ui_script_wall.rs` classifies anything under `ui_*` as feed code
|
||||
//! rather than gameplay, which is the family allowed to hold the VM. This file is a queue in and
|
||||
//! an event out, which is exactly what that remedy is for (`ui_cast_events.rs` carries the same
|
||||
//! note).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{ScriptValue, SelfCast, UiScript};
|
||||
|
||||
use crate::net::{GuidIndex, SelfGuid, SelfPlayer};
|
||||
use crate::spell::{ActiveChannel, AutoRepeatActive, Cooldowns, PendingCast, QueuedMeleeSpell};
|
||||
use crate::ui_script::UiClock;
|
||||
use crate::ui_unit::UnitFeed;
|
||||
|
||||
/// nampower's `castType`, decided at the commit and reported unchanged for the cast's life — the
|
||||
/// legend its own `GetCurrentCastingInfo` fallback spells out (`NampowerAPI.lua:1877`, "3 =
|
||||
/// CHANNEL, 0 = NORMAL") and upstream's `SPELL_CAST_EVENT` documents in full.
|
||||
///
|
||||
/// Numbers, not an enum, because they cross to Lua as numbers and an addon compares them against
|
||||
/// these literals; the names are here so the call sites read.
|
||||
pub(crate) mod cast_type {
|
||||
pub(crate) const NORMAL: u8 = 0;
|
||||
pub(crate) const NON_GCD: u8 = 1;
|
||||
pub(crate) const ON_SWING: u8 = 2;
|
||||
pub(crate) const CHANNEL: u8 = 3;
|
||||
pub(crate) const TARGETING: u8 = 4;
|
||||
pub(crate) const TARGETING_NON_GCD: u8 = 5;
|
||||
}
|
||||
|
||||
/// One committed cast of the player's own, as `SPELL_CAST_EVENT`'s five arguments describe it.
|
||||
///
|
||||
/// `success` is not a column: every row here is a commit that reached the wire. Upstream fires the
|
||||
/// event with `success = 0` for a cast its hook of the client's `TryCast` saw refused, and this
|
||||
/// client's refusals all funnel through `CastErrors` (`ui_action/errors.rs`) — which carries a
|
||||
/// spell id and a reason byte, and neither a cast type nor a target. A row from there would have
|
||||
/// to invent two of these four fields, and nothing in the corpus reads the failing branch at all:
|
||||
/// `SuperCleveRoidMacros`' handler is one `if success == 1 then` with no else (`Core.lua:5177`).
|
||||
/// So the event fires for commits, and the absent half is written down here rather than faked.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct SelfCastSend {
|
||||
pub(crate) spell_id: u32,
|
||||
pub(crate) cast_type: u8,
|
||||
/// The cast's effective target, `0` for none.
|
||||
///
|
||||
/// The bound wire target when the spell has one, else the current selection: upstream's
|
||||
/// `SpellCastEventDefaultTarget` change (its own version table dates it to 2.41, and we report
|
||||
/// 4.1.3) made `targetGuid` default to the current target where it used to send the null GUID,
|
||||
/// and a consumer that correlates a cast against a unit wants the unit the player was looking
|
||||
/// at. A self-implicit buff therefore reports the selection too, which is what that change
|
||||
/// says to do.
|
||||
pub(crate) target: u64,
|
||||
/// The item whose use started this cast, `0` for a spell.
|
||||
pub(crate) item_id: u32,
|
||||
}
|
||||
|
||||
/// The `SPELL_CAST_EVENT` queue, plus the last commit's own columns.
|
||||
///
|
||||
/// The queue is drained every frame; `last` is not, because `GetCastInfo` reports the *live*
|
||||
/// cast's type and target and a cast outlives the frame it was sent in. One resource holds both so
|
||||
/// the event and the getter cannot disagree about which cast they mean — the same reason
|
||||
/// `ui_cast_events.rs` folds its windows and its events off one queue.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct SelfCastSends {
|
||||
queue: Vec<SelfCastSend>,
|
||||
last: Option<SelfCastSend>,
|
||||
}
|
||||
|
||||
impl SelfCastSends {
|
||||
/// Record a commit: one event row, and the columns the getter reports until the next one.
|
||||
pub(crate) fn push(&mut self, row: SelfCastSend) {
|
||||
self.last = Some(row);
|
||||
self.queue.push(row);
|
||||
}
|
||||
|
||||
/// The live cast's `castType`, for a cast this feed can see but no commit of ours armed — a
|
||||
/// cast started by something other than the send path has no row, and `NORMAL` is then the
|
||||
/// honest answer rather than a stale type from an older cast.
|
||||
fn cast_type_for(&self, spell_id: u32) -> u8 {
|
||||
self.last
|
||||
.filter(|r| r.spell_id == spell_id)
|
||||
.map_or(cast_type::NORMAL, |r| r.cast_type)
|
||||
}
|
||||
|
||||
fn target_for(&self, spell_id: u32) -> u64 {
|
||||
self.last
|
||||
.filter(|r| r.spell_id == spell_id)
|
||||
.map_or(0, |r| r.target)
|
||||
}
|
||||
}
|
||||
|
||||
/// Push this tick's [`SelfCast`] and fire the queued `SPELL_CAST_EVENT` rows.
|
||||
///
|
||||
/// Order inside the frame: the snapshot goes in **before** the events, so a handler woken by
|
||||
/// `SPELL_CAST_EVENT` that turns round and calls `GetCurrentCastingInfo` — which
|
||||
/// `SuperCleveRoidMacros` does on the first line of its handler (`Core.lua:5173`,
|
||||
/// `UpdateCastingState()`) — reads the state the event is about, not the state before it.
|
||||
pub(crate) fn feed_self_cast(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
clock: Res<UiClock>,
|
||||
mut sends: ResMut<SelfCastSends>,
|
||||
pending: Res<PendingCast>,
|
||||
queued_melee: Res<QueuedMeleeSpell>,
|
||||
channel: Res<ActiveChannel>,
|
||||
auto_repeat: Res<AutoRepeatActive>,
|
||||
cooldowns: Res<Cooldowns>,
|
||||
self_guid: Res<SelfGuid>,
|
||||
index: Res<GuidIndex>,
|
||||
casting: Query<&crate::creature_anim::Casting>,
|
||||
engaged: Query<(), (With<SelfPlayer>, With<crate::creature_anim::Engaged>)>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
// No VM (a capture or headless run): drop the rows so the queue stays bounded, as every
|
||||
// other drain here does. `last` survives, because it is cast state and not a message.
|
||||
sends.queue.clear();
|
||||
return;
|
||||
};
|
||||
let now = Instant::now();
|
||||
// The cast that owns the bar: the self `Casting` spell id, which is what `0xceac48` holds and
|
||||
// what `ui_cast.rs`'s own bar feed reads for the same purpose.
|
||||
let visual = self_guid
|
||||
.0
|
||||
.as_ref()
|
||||
.and_then(|g| index.0.get(g))
|
||||
.and_then(|&e| casting.get(e).ok())
|
||||
.map(|c| c.spell_id);
|
||||
let inflight = pending.committed(now);
|
||||
let channeling = channel.current(now);
|
||||
// A spell id to report the type and target of: the channel first, since a channel is the one
|
||||
// state that survives its `PendingCast` record (the slot is 0 mid-channel, `0x6e6e80`).
|
||||
let live = channeling.or(inflight).or(visual);
|
||||
let gcd = cooldowns.gcd(now);
|
||||
script.set_self_cast(SelfCast {
|
||||
inflight_spell: inflight.unwrap_or(0),
|
||||
visual_spell: visual.unwrap_or(0),
|
||||
auto_repeat_spell: auto_repeat.0.unwrap_or(0),
|
||||
// Upstream's `casting` and `channeling` are independent columns, and the consumer tests
|
||||
// `casting == 1 or channeling == 1` (`Core.lua:304-311`), so a channel must not light
|
||||
// this one as well — `PendingCast` is already empty mid-channel, and the auto-repeat key
|
||||
// is included because `0xceca88` records a ranged shot too.
|
||||
casting: inflight.is_some() || auto_repeat.0.is_some(),
|
||||
channeling: channeling.is_some(),
|
||||
on_swing: queued_melee.current().is_some(),
|
||||
auto_attack: !engaged.is_empty(),
|
||||
cast_id: pending.cast_seq(),
|
||||
cast_type: match (channeling, live) {
|
||||
// A channel is type 3 whatever its commit said: the commit cannot know, since
|
||||
// `MSG_CHANNEL_START` is what makes a cast a channel as far as this client is
|
||||
// concerned, and it arrives a round trip later.
|
||||
(Some(_), _) => cast_type::CHANNEL,
|
||||
(None, Some(id)) => sends.cast_type_for(id),
|
||||
(None, None) => cast_type::NORMAL,
|
||||
},
|
||||
target_guid: live.map_or(0, |id| sends.target_for(id)),
|
||||
gcd_start: gcd.map_or(0.0, |(start, _)| ui_stamp(start, &clock)),
|
||||
gcd_end: gcd.map_or(0.0, |(start, duration)| ui_stamp(start + duration, &clock)),
|
||||
});
|
||||
for row in sends.queue.drain(..) {
|
||||
if *crate::net::CAST_TRACE {
|
||||
info!(
|
||||
"cast-trace: SPELL_CAST_EVENT — spell {} type {} target {:#x} item {}",
|
||||
row.spell_id, row.cast_type, row.target, row.item_id
|
||||
);
|
||||
}
|
||||
script.fire_event(
|
||||
"SPELL_CAST_EVENT",
|
||||
vec![
|
||||
// `success`: always 1 here, see `SelfCastSend`'s own note.
|
||||
ScriptValue::Int(1),
|
||||
ScriptValue::Int(i64::from(row.spell_id)),
|
||||
ScriptValue::Int(i64::from(row.cast_type)),
|
||||
ScriptValue::Str(benilla_ui::script::guid_token(row.target)),
|
||||
ScriptValue::Int(i64::from(row.item_id)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An `Instant` on the VM's `GetTime()` clock, through the one conversion every cooldown feed
|
||||
/// uses.
|
||||
fn ui_stamp(at: Instant, clock: &UiClock) -> f64 {
|
||||
crate::spell::cooldowns::ui_stamp(at, clock.anchor, clock.ui_now)
|
||||
}
|
||||
|
||||
/// The self-cast feed, in `UnitFeed` beside the cast-window drain and the stock bar's.
|
||||
pub(crate) struct UiSelfCastPlugin;
|
||||
|
||||
impl Plugin for UiSelfCastPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<SelfCastSends>().add_systems(
|
||||
Update,
|
||||
feed_self_cast
|
||||
.in_set(UnitFeed)
|
||||
// After the local cancel, whose abort clears the slot this reads, and after the
|
||||
// window drain: `GetCastInfo` folds this snapshot together with the player's own
|
||||
// cast window, and a snapshot taken before the window moved would let the two
|
||||
// describe different casts for a frame.
|
||||
.after(crate::spell::LocalCancel)
|
||||
.after(crate::ui_cast_events::drain_cast_edges),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
/// An app with the resources [`feed_self_cast`] reads and a VM to push into. No self player
|
||||
/// entity: `auto_attack` is then false and `visual_spell` 0, which is the quiet state.
|
||||
fn app() -> App {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<SelfCastSends>()
|
||||
.init_resource::<PendingCast>()
|
||||
.init_resource::<QueuedMeleeSpell>()
|
||||
.init_resource::<ActiveChannel>()
|
||||
.init_resource::<AutoRepeatActive>()
|
||||
.init_resource::<Cooldowns>()
|
||||
.init_resource::<SelfGuid>()
|
||||
.init_resource::<GuidIndex>()
|
||||
.init_resource::<UiClock>();
|
||||
app.insert_non_send_resource(UiScript::new().unwrap());
|
||||
app
|
||||
}
|
||||
|
||||
fn seven(app: &App) -> (i64, i64, i64, i64, i64, i64, i64) {
|
||||
app.world()
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval("return GetCurrentCastingInfo()")
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The snapshot is this frame's, read through the verb that answers from it: an armed cast
|
||||
/// lights `casting` and fills the inflight id, a queued strike lights `onswing` alone, and
|
||||
/// the whole thing goes back to zeros when both clear.
|
||||
#[test]
|
||||
fn the_feed_publishes_this_frames_cast_slots() {
|
||||
let mut app = app();
|
||||
app.world_mut().run_system_once(feed_self_cast).unwrap();
|
||||
assert_eq!(seven(&app), (0, 0, 0, 0, 0, 0, 0), "nothing in flight");
|
||||
|
||||
let now = Instant::now();
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingCast>()
|
||||
.arm(133, now, 3_000, true);
|
||||
app.world_mut().resource_mut::<QueuedMeleeSpell>().arm(78);
|
||||
app.world_mut().resource_mut::<AutoRepeatActive>().0 = Some(75);
|
||||
app.world_mut().run_system_once(feed_self_cast).unwrap();
|
||||
assert_eq!(
|
||||
seven(&app),
|
||||
(133, 0, 75, 1, 0, 1, 0),
|
||||
"the inflight id, the auto-repeat key, and the queued strike's own column"
|
||||
);
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval::<i64>("local i = GetCastInfo() return i and 1 or 0")
|
||||
.unwrap(),
|
||||
0,
|
||||
"no cast window has been pushed, so GetCastInfo is still nil — the timing half of \
|
||||
that table comes from the window and it refuses to invent one"
|
||||
);
|
||||
|
||||
// A channel lights its own column and leaves `casting` alone: the reference's inflight
|
||||
// slot is empty mid-channel, and the consumer tests the two separately.
|
||||
app.world_mut().resource_mut::<PendingCast>().clear_if(133);
|
||||
app.world_mut().resource_mut::<AutoRepeatActive>().0 = None;
|
||||
app.world_mut()
|
||||
.resource_mut::<ActiveChannel>()
|
||||
.start(5143, 3_000, now);
|
||||
app.world_mut().run_system_once(feed_self_cast).unwrap();
|
||||
assert_eq!(seven(&app), (0, 0, 0, 0, 1, 1, 0));
|
||||
}
|
||||
|
||||
/// The GCD's two columns, on the frame's own clock, read through the verb that publishes
|
||||
/// them. The anchor/`ui_now` pair is the one `CooldownInfo::ui_triple` uses, so a cooldown
|
||||
/// swirl and this table derive the same start from the same sample.
|
||||
#[test]
|
||||
fn the_gcd_is_published_on_the_vm_clock() {
|
||||
let mut app = app();
|
||||
let now = Instant::now();
|
||||
app.insert_resource(UiClock {
|
||||
anchor: now,
|
||||
ui_now: 100.0,
|
||||
});
|
||||
// A cast window and a `"player"` token for it to be found by, so `GetCastInfo` answers at
|
||||
// all — its GCD columns are only reachable through the table.
|
||||
{
|
||||
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
script.set_now(100.0);
|
||||
script.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
exists: true,
|
||||
has_object: true,
|
||||
guid: 0x2a,
|
||||
name: Some("Tester".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
script.set_unit_casts(vec![benilla_ui::script::UnitCastState {
|
||||
guid: 0x2a,
|
||||
spell_id: 133,
|
||||
start: 100.0,
|
||||
end: 103.0,
|
||||
channel: false,
|
||||
delay_ms: 0,
|
||||
}]);
|
||||
}
|
||||
// Fireball-shaped: the ordinary 133/1500 GCD pair, armed at the send.
|
||||
let spell = benilla_formats::SpellDisplay {
|
||||
start_recovery_category: 133,
|
||||
start_recovery_ms: 1_500,
|
||||
..Default::default()
|
||||
};
|
||||
app.world_mut()
|
||||
.resource_mut::<Cooldowns>()
|
||||
.start_gcd(133, &spell, now);
|
||||
app.world_mut().run_system_once(feed_self_cast).unwrap();
|
||||
|
||||
let (end, left) = app
|
||||
.world()
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval::<(f64, f64)>("local i = GetCastInfo() return i.gcdEndS, i.gcdRemainingMs")
|
||||
.unwrap();
|
||||
assert!(
|
||||
(end - 101.5).abs() < 0.05 && (left - 1_500.0).abs() < 50.0,
|
||||
"the GCD armed at the anchor ends 1.5 s later on the VM clock, got {end} / {left}ms"
|
||||
);
|
||||
|
||||
// The expiry is `Cooldowns::gcd`'s own, pinned in `spell/cooldowns.rs` where `now` is a
|
||||
// parameter: this feed reads the real clock, so a test here cannot advance past 1.5 s
|
||||
// without sleeping for it.
|
||||
}
|
||||
|
||||
/// `SPELL_CAST_EVENT`'s five arguments, through the real dispatch path, and the one ordering
|
||||
/// rule that matters: the snapshot is in place before the handler runs, because
|
||||
/// `SuperCleveRoidMacros`' handler calls `UpdateCastingState()` on its first line.
|
||||
#[test]
|
||||
fn the_commit_fires_spell_cast_event_after_the_snapshot_is_in_place() {
|
||||
let mut app = app();
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.run(
|
||||
r#"
|
||||
seen = {}
|
||||
f = CreateFrame("Frame")
|
||||
f:RegisterEvent("SPELL_CAST_EVENT")
|
||||
f:SetScript("OnEvent", function()
|
||||
local castId = GetCurrentCastingInfo()
|
||||
table.insert(seen, arg1 .. "/" .. arg2 .. "/" .. arg3 .. "/" .. arg4
|
||||
.. "/" .. arg5 .. "/inflight=" .. castId)
|
||||
end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
app.world_mut()
|
||||
.resource_mut::<PendingCast>()
|
||||
.arm(133, Instant::now(), 3_000, true);
|
||||
app.world_mut()
|
||||
.resource_mut::<SelfCastSends>()
|
||||
.push(SelfCastSend {
|
||||
spell_id: 133,
|
||||
cast_type: cast_type::NORMAL,
|
||||
target: 0x63,
|
||||
item_id: 0,
|
||||
});
|
||||
app.world_mut().run_system_once(feed_self_cast).unwrap();
|
||||
|
||||
let seen: Vec<String> = app
|
||||
.world()
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval("return seen")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
seen,
|
||||
vec!["1/133/0/0x0000000000000063/0/inflight=133".to_string()],
|
||||
"success, spellId, castType, the target as a GUID token, itemId — and the handler's \
|
||||
own GetCurrentCastingInfo already sees this cast"
|
||||
);
|
||||
// The queue empties; `last` does not, because GetCastInfo reports the live cast's type
|
||||
// and target for as long as the cast runs.
|
||||
app.world_mut().run_system_once(feed_self_cast).unwrap();
|
||||
let seen: Vec<String> = app
|
||||
.world()
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval("return seen")
|
||||
.unwrap();
|
||||
assert_eq!(seen.len(), 1, "one commit, one event");
|
||||
assert_eq!(
|
||||
app.world().resource::<SelfCastSends>().cast_type_for(133),
|
||||
cast_type::NORMAL
|
||||
);
|
||||
}
|
||||
|
||||
/// No VM: the rows go, so the queue cannot grow without bound in a capture or a headless run.
|
||||
/// `last` stays, because it is cast state and not a message.
|
||||
#[test]
|
||||
fn no_vm_drops_the_rows_and_keeps_the_cast_state() {
|
||||
let mut app = app();
|
||||
app.world_mut().remove_non_send_resource::<UiScript>();
|
||||
app.world_mut()
|
||||
.resource_mut::<SelfCastSends>()
|
||||
.push(SelfCastSend {
|
||||
spell_id: 133,
|
||||
cast_type: cast_type::ON_SWING,
|
||||
target: 0,
|
||||
item_id: 0,
|
||||
});
|
||||
app.world_mut().run_system_once(feed_self_cast).unwrap();
|
||||
let sends = app.world().resource::<SelfCastSends>();
|
||||
assert!(sends.queue.is_empty());
|
||||
assert_eq!(sends.cast_type_for(133), cast_type::ON_SWING);
|
||||
}
|
||||
}
|
||||
@@ -1035,6 +1035,117 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ── nampower's own cast state: `GetCurrentCastingInfo` and `GetCastInfo` ──────────────────
|
||||
//
|
||||
// The two most-called nampower functions in grom's corpus, and the pair every other cast
|
||||
// question there goes through: `SuperCleveRoidMacros`' `UpdateCastingState()` reads both and
|
||||
// nothing else (`Init.lua:80-118`), and its `[casting]`, `[channeling]`, `[onswing]`,
|
||||
// `[gcd]`/`[nogcd]` macro conditionals read what that function wrote.
|
||||
//
|
||||
// Both answer out of `model.self_cast`, pushed once per tick by `benilla-app`'s
|
||||
// `ui_self_cast.rs` from the reference's own cast globals — none of which is descriptor data,
|
||||
// and none of which 1.12's Lua can reach by any other route.
|
||||
|
||||
// `GetCurrentCastingInfo()` -> `castId, visId, autoId, casting, channeling, onswing,
|
||||
// autoattack`. Seven returns, every one a **number**: the three ids are 0 when nothing is in
|
||||
// that slot and the four flags are 1 or 0, which is the shape every call site destructures
|
||||
// (`local castId, visId, autoId, casting, channeling, onswing, autoattack =
|
||||
// GetCurrentCastingInfo()`, `Init.lua:82`).
|
||||
//
|
||||
// Flags as 0 and not nil, which is the one place this family departs from the `1`-or-nil
|
||||
// house style of `PlayerIsMoving` above: the consumer tests `casting == 1` and
|
||||
// `onswing == 1`, so either spelling works for it — but it also *prints* and compares these,
|
||||
// and upstream returns a number per column because it is pushing the client's own words. Nil
|
||||
// would also make the trailing columns ambiguous, since a trailing nil in Lua is
|
||||
// indistinguishable from an absent return.
|
||||
//
|
||||
// `castId` is a **spell id** despite its name: the inflight slot `0xceca88`. Two independent
|
||||
// call sites read it as one (`Init.lua:89` assigns it to `CurrentSpell.castingSpellId`;
|
||||
// `Core.lua:5427-5433` hands it to `C_Spell.GetSpellName`). `GetCastInfo`'s table below
|
||||
// carries `castId` *and* `spellId` as separate columns, and there that first one really is a
|
||||
// counter — the same upstream name for two different numbers, which is why both are spelled
|
||||
// out here.
|
||||
lua.globals().set(
|
||||
"GetCurrentCastingInfo",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
let c = model.self_cast;
|
||||
Ok((
|
||||
i64::from(c.inflight_spell),
|
||||
i64::from(c.visual_spell),
|
||||
i64::from(c.auto_repeat_spell),
|
||||
i64::from(c.casting),
|
||||
i64::from(c.channeling),
|
||||
i64::from(c.on_swing),
|
||||
i64::from(c.auto_attack),
|
||||
))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetCastInfo()` -> one table, or **nil** when no cast or channel is running. Its ten keys
|
||||
// are the DLL's own, read off the string pool in table order (`0xa8854`, see
|
||||
// `docs/ADDON_COMPAT.md`): `castId`, `spellId`, `guid`, `castType`, `castStartS`,
|
||||
// `castEndS`, `castRemainingMs`, `castDurationMs`, `gcdEndS`, `gcdRemainingMs`.
|
||||
//
|
||||
// **nil is the contract, not a shortfall.** `API.IsCasting()` is `GetCastInfo() ~= nil`
|
||||
// (`NampowerAPI.lua:1888`) and `API.GetGCDRemainingMs` falls through to
|
||||
// `GetSpellIdCooldown` when this answers nil, with its own comment saying why ("works after
|
||||
// instant casts"). So an instant's GCD is deliberately *not* reported here: there is no cast,
|
||||
// and inventing a cast-shaped table around a bare GCD would make `IsCasting()` true for a
|
||||
// second and a half after every instant.
|
||||
//
|
||||
// The timing columns come from the player's own cast window — the one
|
||||
// `C_Spell.UnitCastingInfo("player")` reads, folded by `ui_cast_events.rs` off the same
|
||||
// packets — and the identity and GCD columns from this tick's `self_cast`. The feed that
|
||||
// pushes the snapshot declares itself after the drain that folds the window, so the two
|
||||
// halves of this table always describe the same cast.
|
||||
//
|
||||
// `castType` is `3` while the window is a channel and the commit's own type otherwise;
|
||||
// `guid` is the cast's effective target as a GUID token, the zero token when it had none.
|
||||
lua.globals().set(
|
||||
"GetCastInfo",
|
||||
lua.create_function(|lua, ()| {
|
||||
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
// The cast first, then the channel: the two verbs behind these windows are disjoint,
|
||||
// so at most one of them answers.
|
||||
let Some(w) = model
|
||||
.unit_cast("player", false)
|
||||
.or_else(|| model.unit_cast("player", true))
|
||||
else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let c = model.self_cast;
|
||||
let ms = |secs: f64| (secs * 1000.0).max(0.0).round();
|
||||
let t = lua.create_table()?;
|
||||
t.set("castId", i64::from(c.cast_id))?;
|
||||
t.set("spellId", i64::from(w.spell_id))?;
|
||||
t.set("guid", crate::script::guid_token(c.target_guid))?;
|
||||
t.set("castType", i64::from(c.cast_type))?;
|
||||
t.set("castStartS", w.start)?;
|
||||
t.set("castEndS", w.end)?;
|
||||
t.set("castRemainingMs", ms(w.end - now))?;
|
||||
// The window's own span, pushback included: upstream's `castDurationMs` is the cast's
|
||||
// length as it now stands, and `UnitCastingInfo`'s 11th return reports the pushback
|
||||
// separately for a caller that wants the undisturbed one.
|
||||
t.set("castDurationMs", ms(w.end - w.start))?;
|
||||
t.set("gcdEndS", c.gcd_end)?;
|
||||
t.set(
|
||||
"gcdRemainingMs",
|
||||
if c.gcd_end > 0.0 {
|
||||
ms(c.gcd_end - now)
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
)?;
|
||||
Ok(Value::Table(t))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `PlayerIsMoving()` / `PlayerIsRooted()` / `PlayerIsSwimming()` (v2.36+): `1` or **nil**, as
|
||||
// upstream's own doc lines say ("Returns 1 if player is moving, nil if stationary"), not a
|
||||
// boolean — `SuperCleveRoidMacros` branches on truthiness, but an addon printing the value
|
||||
|
||||
@@ -129,6 +129,7 @@ mod region_map;
|
||||
mod reputation;
|
||||
mod saved;
|
||||
mod scrollframe;
|
||||
mod self_cast;
|
||||
mod self_movement;
|
||||
mod server_clock;
|
||||
mod session;
|
||||
@@ -245,7 +246,7 @@ pub use model::{
|
||||
SpellProbe, SpellUsable, TextureProbe, TextureSizeProbe,
|
||||
};
|
||||
pub(crate) use model::{Model, PendingTimer};
|
||||
pub use model::{PlayerCacheEntry, SelfMovement, ServerClock};
|
||||
pub use model::{PlayerCacheEntry, SelfCast, SelfMovement, ServerClock};
|
||||
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo};
|
||||
pub use pet::{PetActionView, PetStats};
|
||||
pub use pvp::{HonorState, InspectHonorData};
|
||||
|
||||
@@ -286,6 +286,73 @@ pub struct SelfMovement {
|
||||
pub mouselooking: bool,
|
||||
}
|
||||
|
||||
/// The player's own cast state, pushed once per tick (`benilla-app`'s `ui_self_cast.rs`) for
|
||||
/// nampower's [`GetCurrentCastingInfo`](crate::script::compat) and `GetCastInfo` — the two
|
||||
/// most-called nampower functions in grom's corpus.
|
||||
///
|
||||
/// **Why a snapshot and not a read of `unit_casts`.** The cast *window* (`UnitCastState`) is what
|
||||
/// a bar draws: one row per caster, start and end, nothing about the caster's own machinery. These
|
||||
/// five ids and four flags are the reference's own cast globals — the inflight slot `0xceca88`,
|
||||
/// the cast with the bar `0xceac48`, the auto-repeat key `0xceac30`, the queued strike, the
|
||||
/// melee toggle — which live in `benilla-app`'s resources and have no descriptor field at all.
|
||||
/// `GetCastInfo` then folds the two: its timing columns come from the player's own window, its
|
||||
/// identity and GCD columns from here, pushed in the same frame by a system ordered after the
|
||||
/// window drain so the pair can never describe different casts.
|
||||
///
|
||||
/// `Default` (everything zero) is what a frame before the feed's first run reads, and it is also
|
||||
/// exactly "not casting", which is the honest answer then.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct SelfCast {
|
||||
/// `GetCurrentCastingInfo`'s first return, which upstream spells `castId` and every consumer
|
||||
/// reads as a **spell id**: `SuperCleveRoidMacros` assigns it to `CurrentSpell.castingSpellId`
|
||||
/// (`Init.lua:89`) and hands it to `C_Spell.GetSpellName` (`Core.lua:5427-5433`). It is the
|
||||
/// reference's inflight slot `0xceca88` — the last committed, unresolved cast of any class.
|
||||
pub inflight_spell: u32,
|
||||
/// `visId`: the cast that owns the bar, the reference's `0xceac48`. Read as a spell id too
|
||||
/// (`Core.lua:5347`, the `GetCastInfo` fallback), and `Init.lua:99` stores it as
|
||||
/// `visualSpellId`.
|
||||
pub visual_spell: u32,
|
||||
/// `autoId`: the live auto-repeat spell, the reference's `0xceac30`.
|
||||
pub auto_repeat_spell: u32,
|
||||
/// `casting`: a cast or auto-repeat is in flight. Deliberately **not** the same bool as
|
||||
/// [`Model::casting`](crate::script) — that one answers `SpellStopCasting`'s question (what
|
||||
/// Esc can stop, which includes an auto-repeat and excludes a channel), and upstream's pair
|
||||
/// here is `casting` *and* `channeling` as two independent columns, which is why the consumer
|
||||
/// tests `casting == 1 or channeling == 1`.
|
||||
pub casting: bool,
|
||||
/// `channeling`: a channel is running (`0xceac58`).
|
||||
pub channeling: bool,
|
||||
/// `onswing`: an on-next-swing strike is queued (Heroic Strike, Cleave). Three macro
|
||||
/// conditionals read this column alone (`Conditionals.lua:7587`, `:7594`, `:9120`).
|
||||
pub on_swing: bool,
|
||||
/// `autoattack`: melee auto-attack is running. `Core.lua:94` reads this one with no existence
|
||||
/// guard at all — `local _,_,_,_,_,_,autoattack = GetCurrentCastingInfo()` — so on a client
|
||||
/// without the function that line raises.
|
||||
pub auto_attack: bool,
|
||||
/// `GetCastInfo`'s `castId`, which is a **different** number from
|
||||
/// [`Self::inflight_spell`](Self) despite the shared upstream name: that table carries
|
||||
/// `castId` and `spellId` as separate columns, so this one is the monotonic count of
|
||||
/// committed casts (`PendingCast::arm`'s own counter), not a spell id.
|
||||
pub cast_id: u32,
|
||||
/// `GetCastInfo`'s `castType`, decided at the commit: `0` normal, `1` non-GCD, `2` on-swing,
|
||||
/// `3` channel, `4` targeting, `5` targeting non-GCD. The channel value is applied by the
|
||||
/// binding from the live window rather than stored here, because a cast's type is fixed at
|
||||
/// send and whether it is *currently* channelling is not.
|
||||
pub cast_type: u8,
|
||||
/// `GetCastInfo`'s `guid`: the committed cast's target. Written as a GUID token by the
|
||||
/// binding, `0` meaning no explicit target.
|
||||
pub target_guid: u64,
|
||||
/// The running global cooldown on the `GetTime()` clock — `GetCastInfo`'s `gcdEndS`, with
|
||||
/// `gcdRemainingMs` derived from it. `0.0` for both when no GCD is running.
|
||||
///
|
||||
/// One number for what 1.12 keys per `startRecoveryCategory`, because that is the shape the
|
||||
/// consumer wants: `API.GetGCDRemainingMs` divides it and the `[gcd]`/`[nogcd]` macro
|
||||
/// conditionals compare it against a number of seconds. The app publishes the **longest**
|
||||
/// running GCD leg, which is the one a player is actually waiting on.
|
||||
pub gcd_start: f64,
|
||||
pub gcd_end: f64,
|
||||
}
|
||||
|
||||
/// Minted id to handle, indexed directly: ids are dense and every scripted call looks one up.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct IdMap<T>(Vec<Option<T>>);
|
||||
@@ -1331,6 +1398,8 @@ pub(crate) struct Model {
|
||||
/// lowercase-initial siblings pending): not 1.12 verbs, so fed only for `"player"`, not the
|
||||
/// generic per-token reach the rest of `UnitState` has.
|
||||
pub(crate) self_movement: SelfMovement,
|
||||
/// The player's own cast machinery, pushed once per tick beside it.
|
||||
pub(crate) self_cast: SelfCast,
|
||||
|
||||
/// A `Minimap:PingLocation(x, y)`, drained the same frame: centre-relative offsets in UI units,
|
||||
/// `GetCursorPosition`'s space, not the window pixels of the app's minimap geometry.
|
||||
@@ -1998,6 +2067,7 @@ impl Model {
|
||||
timers_cancelled: Vec::new(),
|
||||
next_timer_id: 0,
|
||||
self_movement: SelfMovement::default(),
|
||||
self_cast: SelfCast::default(),
|
||||
minimap_ping_request: None,
|
||||
minimap_ping: (0.0, 0.0),
|
||||
chat_sends: Vec::new(),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
//! The self player's own cast machinery ([`SelfCast`]): the reference's cast globals, which live
|
||||
//! in `benilla-app`'s resources and have no descriptor field, pushed once per tick for nampower's
|
||||
//! `GetCurrentCastingInfo` and `GetCastInfo` (`compat/nampower.rs`).
|
||||
|
||||
use super::SelfCast;
|
||||
|
||||
impl super::UiScript {
|
||||
/// Push this tick's self-cast snapshot.
|
||||
pub fn set_self_cast(&mut self, cast: SelfCast) {
|
||||
self.model_mut().self_cast = cast;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ mod regions;
|
||||
mod resize_bounds;
|
||||
mod runaway_frame;
|
||||
mod scrollframe;
|
||||
mod self_cast;
|
||||
mod setparent;
|
||||
mod simplehtml;
|
||||
mod size_changed;
|
||||
|
||||
@@ -350,6 +350,19 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
|
||||
// from the same predicate `benilla-app`'s `ui_aura::shown_in_aura_ui` applies to the
|
||||
// lists, so it cannot contradict the aura list an addon reads beside it.
|
||||
"IsAuraHidden",
|
||||
// nampower's own cast state (same file), the pair every other cast question in grom's
|
||||
// corpus goes through — 25 and 20 call sites outside the API wrapper, which makes them
|
||||
// the most-called nampower functions installed here.
|
||||
//
|
||||
// 1.12 has no spelling for either and cannot grow one: what they report is the client's
|
||||
// own cast globals (the inflight slot, the cast that owns the bar, the auto-repeat key,
|
||||
// the queued strike, the melee attack lock, the global cooldown's end), and that client
|
||||
// publishes *events* about its cast — `SPELLCAST_START`, `SPELLCAST_STOP` — and not one
|
||||
// getter that answers what is in flight. `GetCastInfo` folds the same timing a stock
|
||||
// cast bar draws from `SPELLCAST_START`'s own arguments, which is why its table cannot
|
||||
// disagree with the bar: both come off this frame's one cast window.
|
||||
"GetCurrentCastingInfo",
|
||||
"GetCastInfo",
|
||||
// ClassicAPI's Enum (script/compat/classic_api/enums.rs): the modern client's named
|
||||
// constants, so an addon can write `Enum.PowerType.Mana` for 0. Pure data, no 1.12
|
||||
// counterpart — 1.12 spells the same numbers as bare literals or `LE_*`/`ITEM_QUALITY_*`
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! nampower's own cast state: `GetCurrentCastingInfo` and `GetCastInfo`
|
||||
//! (`compat/nampower.rs`, fed by `benilla-app`'s `ui_self_cast.rs`).
|
||||
//!
|
||||
//! Same approach as `cast_info.rs` next door, and for the same reason: a positional return shape
|
||||
//! and a table of ten keys are only correct relative to the code that reads them, so the test is
|
||||
//! that code — **`SuperCleveRoidMacros`' own bodies, transcribed**. Where the app-side arithmetic
|
||||
//! is pinned (which commit sets which `castType`, when the snapshot is pushed) is
|
||||
//! `benilla-app`'s `ui_self_cast.rs`; what this file pins is the half that lives here.
|
||||
//!
|
||||
//! The bodies, by line (`benilla-config/AddOns/SuperCleveRoidMacros/`):
|
||||
//! - `NampowerAPI.lua:1862-1885` `API.GetCastInfo`, including the `GetCurrentCastingInfo`
|
||||
//! fallback it keeps for older nampower — which is itself a second reader of the seven returns.
|
||||
//! - `NampowerAPI.lua:1888-1892` `API.IsCasting`, which is `GetCastInfo() ~= nil` and nothing
|
||||
//! else, and so is the one test that says what nil has to mean.
|
||||
//! - `NampowerAPI.lua:1898-1921` `API.GetGCDRemainingMs` and `:1926-1932`
|
||||
//! `API.GetCastRemainingMs`, the two that divide these columns.
|
||||
//! - `Init.lua:79-118` `CleveRoids.UpdateCastingState`, which reads both verbs and is where every
|
||||
//! `[casting]`/`[channeling]`/`[onswing]` macro conditional gets its answer.
|
||||
|
||||
use crate::script::{SelfCast, UiScript, UnitCastState, UnitState};
|
||||
|
||||
/// Frostbolt's real id, as `cast_info.rs` uses it.
|
||||
const FROSTBOLT: u32 = 116;
|
||||
|
||||
/// A VM with `GetTime()` parked at 100 s and `"player"` naming one GUID — the key every window is
|
||||
/// found by.
|
||||
fn harness() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_now(100.0);
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
has_object: true,
|
||||
guid: 0x2a,
|
||||
name: Some("Tester".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
fn window(spell_id: u32, start: f64, end: f64, channel: bool) -> UnitCastState {
|
||||
UnitCastState {
|
||||
guid: 0x2a,
|
||||
spell_id,
|
||||
start,
|
||||
end,
|
||||
channel,
|
||||
delay_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `NampowerAPI.lua:1862-1932`, the four wrappers transcribed. `API.features.*` is replaced by the
|
||||
/// plain `_G` test `HasFeature` reduces to once the version requirement is met, which it is: we
|
||||
/// report 4.1.3 and these need 2.18 and 2.0.
|
||||
const NAMPOWER_API: &str = r#"
|
||||
API = {}
|
||||
function API.GetCastInfo()
|
||||
if GetCastInfo then
|
||||
local ok, result = pcall(GetCastInfo)
|
||||
if ok then return result end
|
||||
end
|
||||
if GetCurrentCastingInfo then
|
||||
local castId, visId, autoId, casting, channeling, onswing, autoattack = GetCurrentCastingInfo()
|
||||
if castId and castId > 0 then
|
||||
return { spellId = castId, castType = channeling == 1 and 3 or 0 }
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
function API.IsCasting()
|
||||
local info = API.GetCastInfo()
|
||||
return info ~= nil
|
||||
end
|
||||
function API.GetGCDRemainingMs()
|
||||
local info = API.GetCastInfo()
|
||||
if info and info.gcdRemainingMs and info.gcdRemainingMs > 0 then
|
||||
return info.gcdRemainingMs
|
||||
end
|
||||
return 0
|
||||
end
|
||||
function API.GetCastRemainingMs()
|
||||
local info = API.GetCastInfo()
|
||||
if info and info.castRemainingMs then return info.castRemainingMs end
|
||||
return 0
|
||||
end
|
||||
"#;
|
||||
|
||||
/// `Init.lua:79-118`, the state machine every macro conditional reads, with its own comments kept
|
||||
/// where they state a rule this has to honour.
|
||||
const UPDATE_CASTING_STATE: &str = r#"
|
||||
CurrentSpell = { type = "", castingSpellId = nil }
|
||||
function UpdateCastingState()
|
||||
if not GetCurrentCastingInfo then return false end
|
||||
local castId, visId, autoId, casting, channeling, onswing, autoattack = GetCurrentCastingInfo()
|
||||
if casting == 1 then
|
||||
CurrentSpell.type = "cast"
|
||||
CurrentSpell.castingSpellId = castId
|
||||
elseif CurrentSpell.type == "cast" then
|
||||
CurrentSpell.type = ""
|
||||
CurrentSpell.castingSpellId = nil
|
||||
end
|
||||
CurrentSpell.autoAttack = (autoattack == 1)
|
||||
CurrentSpell.onSwingPending = (onswing == 1)
|
||||
CurrentSpell.visualSpellId = visId
|
||||
CurrentSpell.autoRepeatSpellId = autoId
|
||||
if GetCastInfo then
|
||||
local ok, info = pcall(GetCastInfo)
|
||||
if ok and info then
|
||||
CurrentSpell.castRemainingMs = info.castRemainingMs
|
||||
CurrentSpell.castEndTime = info.castEndS
|
||||
CurrentSpell.gcdRemainingMs = info.gcdRemainingMs
|
||||
CurrentSpell.gcdEndTime = info.gcdEndS
|
||||
else
|
||||
CurrentSpell.castRemainingMs = nil
|
||||
CurrentSpell.castEndTime = nil
|
||||
CurrentSpell.gcdRemainingMs = nil
|
||||
CurrentSpell.gcdEndTime = nil
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
"#;
|
||||
|
||||
/// The seven returns, in the shape `Init.lua:82` destructures: every one a number, the three ids
|
||||
/// `0` when their slot is empty and the four flags `1`/`0`.
|
||||
///
|
||||
/// `casting` and `channeling` are **independent** columns, which `Core.lua:304-311` relies on
|
||||
/// (`if (casting == 1 or channeling == 1) and ...`): a channel must light the second alone, or a
|
||||
/// consumer that branches on `casting` first would treat a channel as an ordinary cast.
|
||||
#[test]
|
||||
fn get_current_casting_info_answers_seven_numbers() {
|
||||
let mut s = harness();
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, i64, i64, i64, i64, i64)>("return GetCurrentCastingInfo()")
|
||||
.unwrap(),
|
||||
(0, 0, 0, 0, 0, 0, 0),
|
||||
"nothing in flight: zeros, not nils — a trailing nil is indistinguishable from an \
|
||||
absent return, which would make the last columns unreadable"
|
||||
);
|
||||
|
||||
s.set_self_cast(SelfCast {
|
||||
inflight_spell: FROSTBOLT,
|
||||
visual_spell: FROSTBOLT,
|
||||
auto_repeat_spell: 0,
|
||||
casting: true,
|
||||
channeling: false,
|
||||
on_swing: false,
|
||||
auto_attack: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, i64, i64, i64, i64, i64)>("return GetCurrentCastingInfo()")
|
||||
.unwrap(),
|
||||
(116, 116, 0, 1, 0, 0, 1)
|
||||
);
|
||||
|
||||
// `Init.lua`'s own reading of it: `castId` is a **spell id**, and the two metadata flags are
|
||||
// stored as Lua booleans off an `== 1` test.
|
||||
s.run(UPDATE_CASTING_STATE).unwrap();
|
||||
assert!(s.eval::<bool>("return UpdateCastingState()").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<(String, i64, bool, bool)>(
|
||||
"return CurrentSpell.type, CurrentSpell.castingSpellId, \
|
||||
CurrentSpell.autoAttack, CurrentSpell.onSwingPending"
|
||||
)
|
||||
.unwrap(),
|
||||
("cast".to_string(), 116, true, false)
|
||||
);
|
||||
|
||||
// A channel lights `channeling` alone, and the consumer's `casting == 1` branch therefore
|
||||
// clears the cast state it had — which is exactly what `Init.lua`'s `elseif` is for.
|
||||
s.set_self_cast(SelfCast {
|
||||
channeling: true,
|
||||
on_swing: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>(
|
||||
"local _, _, _, casting, channeling = GetCurrentCastingInfo() \
|
||||
return casting, channeling"
|
||||
)
|
||||
.unwrap(),
|
||||
(0, 1)
|
||||
);
|
||||
assert!(s.eval::<bool>("return UpdateCastingState()").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<(String, bool)>("return CurrentSpell.type, CurrentSpell.onSwingPending")
|
||||
.unwrap(),
|
||||
(String::new(), true)
|
||||
);
|
||||
}
|
||||
|
||||
/// `GetCastInfo`'s ten keys, their units, and the one rule the whole family rests on: **nil when
|
||||
/// nothing is casting**. `API.IsCasting()` is that nil and nothing else.
|
||||
#[test]
|
||||
fn get_cast_info_is_nil_until_a_window_opens_then_carries_ten_keys() {
|
||||
let mut s = harness();
|
||||
s.run(NAMPOWER_API).unwrap();
|
||||
|
||||
// No window: nil from the native verb, and the fallback finds nothing either, so `IsCasting`
|
||||
// is false and both divisors are 0 rather than an error.
|
||||
assert!(s
|
||||
.eval::<Option<mlua::Value>>("return GetCastInfo()")
|
||||
.unwrap()
|
||||
.is_none_or(|v| v.is_nil()));
|
||||
assert!(!s.eval::<bool>("return API.IsCasting()").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return API.GetGCDRemainingMs()").unwrap(),
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return API.GetCastRemainingMs()").unwrap(),
|
||||
0.0
|
||||
);
|
||||
|
||||
// A 3 s Frostbolt a third of the way through, with the 1.5 s GCD armed at its send, and the
|
||||
// commit's own columns beside it.
|
||||
s.set_unit_casts(vec![window(FROSTBOLT, 99.0, 102.0, false)]);
|
||||
s.set_self_cast(SelfCast {
|
||||
inflight_spell: FROSTBOLT,
|
||||
visual_spell: FROSTBOLT,
|
||||
casting: true,
|
||||
cast_id: 7,
|
||||
cast_type: 0,
|
||||
target_guid: 0x63,
|
||||
gcd_start: 99.0,
|
||||
gcd_end: 100.5,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert!(s.eval::<bool>("return API.IsCasting()").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, String, i64)>(
|
||||
"local i = GetCastInfo() return i.castId, i.spellId, i.guid, i.castType"
|
||||
)
|
||||
.unwrap(),
|
||||
(7, 116, "0x0000000000000063".to_string(), 0),
|
||||
"castId is a counter and spellId a spell — the same upstream name for two numbers, so \
|
||||
both are asserted; guid is the cast's target in the one GUID spelling every compat verb \
|
||||
writes"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, f64, f64)>(
|
||||
"local i = GetCastInfo() \
|
||||
return i.castStartS, i.castEndS, i.castRemainingMs, i.castDurationMs"
|
||||
)
|
||||
.unwrap(),
|
||||
(99.0, 102.0, 2000.0, 3000.0),
|
||||
"the two S columns are GetTime() seconds and the two Ms columns milliseconds, which is \
|
||||
the mixed-unit table upstream really hands back"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>("local i = GetCastInfo() return i.gcdEndS, i.gcdRemainingMs")
|
||||
.unwrap(),
|
||||
(100.5, 500.0)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return API.GetGCDRemainingMs()").unwrap(),
|
||||
500.0,
|
||||
"the wrapper's fast path, which is what the [gcd] macro conditional divides by 1000"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return API.GetCastRemainingMs()").unwrap(),
|
||||
2000.0
|
||||
);
|
||||
|
||||
// A lapsed window still answers — the drain prunes it, not this verb — and the remainders
|
||||
// floor at zero rather than going negative, which a consumer comparing `> 0` depends on.
|
||||
s.set_now(103.0);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>("local i = GetCastInfo() return i.castRemainingMs, i.gcdRemainingMs")
|
||||
.unwrap(),
|
||||
(0.0, 0.0)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return API.GetGCDRemainingMs()").unwrap(),
|
||||
0.0
|
||||
);
|
||||
}
|
||||
|
||||
/// A channel answers `GetCastInfo` too — `castType` 3 — where it answers
|
||||
/// `C_Spell.UnitCastingInfo` not at all. The two families differ on purpose: ClassicAPI's pair is
|
||||
/// disjoint by design (a channel is `UnitChannelInfo`'s), and nampower has one getter for both.
|
||||
#[test]
|
||||
fn a_channel_answers_get_cast_info_as_type_three() {
|
||||
let mut s = harness();
|
||||
s.set_unit_casts(vec![window(15_407, 100.0, 103.0, true)]);
|
||||
s.set_self_cast(SelfCast {
|
||||
channeling: true,
|
||||
cast_id: 2,
|
||||
cast_type: 3,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, f64)>(
|
||||
"local i = GetCastInfo() return i.spellId, i.castType, i.castDurationMs"
|
||||
)
|
||||
.unwrap(),
|
||||
(15_407, 3, 3000.0)
|
||||
);
|
||||
// And the ClassicAPI pair still splits them the way its own consumers assume.
|
||||
assert!(s
|
||||
.eval::<Option<String>>(r#"local n = C_Spell.UnitCastingInfo("player") return n"#)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
/// The GCD when **no** cast is running is deliberately not reported: there is no window, so
|
||||
/// `GetCastInfo` is nil, so `API.IsCasting()` is false and `API.GetGCDRemainingMs` falls through
|
||||
/// to its `GetSpellIdCooldown` path — whose own comment says it is the one that "works after
|
||||
/// instant casts".
|
||||
///
|
||||
/// Pinning this because the tempting shortcut is the opposite: answer a table whenever the GCD is
|
||||
/// up, so the fast path always hits. That would make `IsCasting()` true for a second and a half
|
||||
/// after every instant cast, and `[casting]` is a macro conditional players gate abilities on.
|
||||
#[test]
|
||||
fn an_instants_gcd_alone_does_not_make_a_cast() {
|
||||
let mut s = harness();
|
||||
s.run(NAMPOWER_API).unwrap();
|
||||
s.set_self_cast(SelfCast {
|
||||
cast_id: 9,
|
||||
gcd_start: 100.0,
|
||||
gcd_end: 101.5,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(s
|
||||
.eval::<Option<mlua::Value>>("return GetCastInfo()")
|
||||
.unwrap()
|
||||
.is_none_or(|v| v.is_nil()));
|
||||
assert!(!s.eval::<bool>("return API.IsCasting()").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return API.GetGCDRemainingMs()").unwrap(),
|
||||
0.0,
|
||||
"0 from the wrapper's own `return 0` tail, which is its signal to try the other path"
|
||||
);
|
||||
}
|
||||
|
||||
/// The fallback arm of `API.GetCastInfo` is a second reader of `GetCurrentCastingInfo`'s returns,
|
||||
/// and it reads the first one as a **spell id** (`spellId = castId`). It only runs when the native
|
||||
/// verb is absent, so the test removes it — which is also the one case where `castId > 0` has to
|
||||
/// mean "something is casting".
|
||||
#[test]
|
||||
fn the_wrappers_fallback_arm_reads_the_first_return_as_a_spell_id() {
|
||||
let mut s = harness();
|
||||
s.run(NAMPOWER_API).unwrap();
|
||||
s.run("GetCastInfo = nil").unwrap();
|
||||
s.set_self_cast(SelfCast {
|
||||
inflight_spell: FROSTBOLT,
|
||||
casting: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("local i = API.GetCastInfo() return i.spellId, i.castType")
|
||||
.unwrap(),
|
||||
(116, 0)
|
||||
);
|
||||
s.set_self_cast(SelfCast {
|
||||
inflight_spell: 15_407,
|
||||
channeling: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return API.GetCastInfo().castType").unwrap(),
|
||||
3,
|
||||
"the fallback derives type 3 from the channeling flag, which is why that column has to \
|
||||
be a 1 and not a Lua true"
|
||||
);
|
||||
}
|
||||
+50
-14
@@ -573,12 +573,10 @@ config export, but they are a different kind of surface from a getter, and the r
|
||||
client that did not have them yesterday is not to grow them silently. `CombatLogFlush` is here only
|
||||
because it is in the same family — it would flush our own `WoWCombatLog.txt`, which is benign.
|
||||
|
||||
**Needs a feed this client does not have, with what it would take (6).**
|
||||
**Needs a feed this client does not have, with what it would take (4).**
|
||||
|
||||
| Name | What is missing |
|
||||
|---|---|
|
||||
| `GetCastInfo()` | Its table is `castId, spellId, guid, castType, castStartS, castEndS, castRemainingMs, castDurationMs, gcdEndS, gcdRemainingMs` (`NampowerAPI.lua:1859`). `PendingCast` (`spell/inflight.rs`) carries a spell id, a deadline and a guard bit — no start instant, no channel state, no GCD end. The consumer **divides by these** (`API.GetGCDRemainingMs`), so a table of three real fields and seven zeros is worse than the nil its own fallback already handles correctly. Needs `SMSG_SPELL_START`'s cast time kept as a start/end pair, a channel flag, and the GCD end the action bar already computes but does not publish |
|
||||
| `GetCurrentCastingInfo()` | `castId, visId, autoId, casting, channeling, onswing, autoattack`. Same gap: `model.casting` is one bool that explicitly excludes channels, and `visId`/`autoId`/`onswing` are queue state this client has none of |
|
||||
| `GetSpellRangeData(rangeIndex)` | A `SpellRange.dbc` **row index**, per the DLL's own usage string — not a spell id, which is how this row read until the usage strings were compared against it. `IsSpellInRange` answers the *verdict* from the live usable map, never the yards, so the table is genuinely absent. One more probe beside `install_spell_extras_probes` |
|
||||
| `GetSpellModifiers(spellId)` | `spell/mods.rs` stores the server's `SPELLMOD_*` cells but publishes no reader |
|
||||
| `GetUnitData(unit)` | The whole descriptor as a table, where `GetUnitField` answers one field. Needs a decision about which fields: the real one returns GUID-typed columns (charm, summon, charmedBy) this client's field reader does not type |
|
||||
@@ -591,6 +589,49 @@ index, rank)` is left out for a narrower reason: `LearnTalent` here learns the *
|
||||
reaching a named rank means queueing the pair once per missing rank, and whether the server accepts
|
||||
that burst is not something this client can check before sending.
|
||||
|
||||
### The cast feed — `GetCurrentCastingInfo`, `GetCastInfo` and `SPELL_CAST_EVENT`
|
||||
|
||||
**Built**, and the reason this pair came first is measurement rather than taste: they are the
|
||||
most-called nampower functions in grom's corpus (25 and 20 sites outside `NampowerAPI.lua`), and
|
||||
one of those sites calls `GetCurrentCastingInfo` with **no existence guard at all** —
|
||||
`local _,_,_,_,_,_,autoattack = GetCurrentCastingInfo()` (`SuperCleveRoidMacros/Core.lua:94`), in
|
||||
the deferred-stop frame that keeps clearing the target while an autoattack is queued. On a client
|
||||
without the function that line raises.
|
||||
|
||||
What the earlier "needs a feed" row said was true of `PendingCast` and wrong about the client as a
|
||||
whole: this tree already folds a **cast window per caster** off the same packets (`UnitCastState`
|
||||
in `ui_cast_events.rs`, behind `C_Spell.UnitCastingInfo`), with a start, an end, a channel bit and
|
||||
the accumulated pushback. What was genuinely missing was the *caster's own machinery* — the
|
||||
reference's cast globals — and the GCD, which `spell/cooldowns.rs` has run correctly all along and
|
||||
never published.
|
||||
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| `SelfCast`, eleven columns: the inflight spell `0xceca88`, the bar's spell `0xceac48`, the auto-repeat key `0xceac30`, four flags, the cast counter, the cast type, the target, and the GCD's start/end on the `GetTime()` clock | `benilla-ui`'s `script/model.rs` + `script/self_cast.rs` |
|
||||
| The per-tick push, and `SPELL_CAST_EVENT` fired off the same commit | `benilla-app`'s `ui_self_cast.rs`, in `UnitFeed` after the cast-window drain so the two halves of `GetCastInfo`'s table cannot describe different casts |
|
||||
| `Cooldowns::gcd` — the running global cooldown as one timer | `spell/cooldowns.rs`, the longest running GCD leg, which is the one a player is waiting on; `info` still answers the per-category question every cooldown swirl asks |
|
||||
| `PendingCast`'s cast counter | `spell/inflight.rs`; the one place every class of commit passes through, which is why it is there and not in the send path |
|
||||
| The commit rows (`castType`, target, item) | `spell/cast_send.rs`, at the two commits — the plain send and the targeting-cursor click, which is the only arm that produces `TARGETING`/`TARGETING_NON_GCD` |
|
||||
|
||||
Three decisions worth having written down, because each had a tempting wrong answer:
|
||||
|
||||
- **`GetCastInfo` is nil when nothing is casting, and an instant's GCD does not count.** The
|
||||
shortcut is to answer a table whenever the GCD is up, so the consumer's fast path always hits.
|
||||
But `API.IsCasting()` *is* `GetCastInfo() ~= nil` (`NampowerAPI.lua:1888`), and `[casting]` is a
|
||||
macro conditional players gate abilities on — a table there would make it true for a second and
|
||||
a half after every instant. The wrapper's own fallback comment says what to do instead
|
||||
("`GetSpellIdCooldown` … works for instant casts").
|
||||
- **`castId` means two different numbers under one upstream name.** In `GetCastInfo`'s table it is
|
||||
a counter, beside a separate `spellId` column. In `GetCurrentCastingInfo`'s returns it is a
|
||||
*spell id*, which is how both of its readers treat it (`Init.lua:89` stores it as
|
||||
`castingSpellId`; `Core.lua:5427` hands it to `C_Spell.GetSpellName`). Both are implemented as
|
||||
what their own consumers read.
|
||||
- **`SPELL_CAST_EVENT` fires for commits, not for refusals.** Upstream's `success` column can be
|
||||
0; this client's local refusals all funnel through `CastErrors`, which carries a spell id and a
|
||||
reason byte and neither a cast type nor a target, so a row from there would invent two of five
|
||||
fields. Nothing in the corpus reads the failing branch — `Core.lua:5177` is one
|
||||
`if success == 1 then` with no else.
|
||||
|
||||
### The whole registered list, accounted for
|
||||
|
||||
With the registration table read (the section above), the surface is no longer a count of what a
|
||||
@@ -607,18 +648,13 @@ comm -23 /tmp/np /tmp/ours
|
||||
|
||||
| Bucket | Count | Names |
|
||||
|---|---|---|
|
||||
| **Built** | 32 | `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CastSpellByName`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `FindPlayerItemSlot`, `GetBagItem`, `GetEquippedItem`, `GetEquippedItems`, `GetItemIconTexture`, `GetItemIdCooldown`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`, `GetNampowerVersion`, `GetPlayerAuraDuration`, `GetSpellDuration`, `GetSpellIconTexture`, `GetSpellIdForName`, `GetSpellNameAndRankForId`, `GetSpellRecField`, `GetSpellSlotTypeIdForName`, `GetTrinkets`, `GetUnitField`, `GetUnitGUID`, `IsAuraHidden`, `IsSpellInRange`, `IsSpellUsable`, `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `SetMouseoverUnit`, `UseItemIdOrName` |
|
||||
| **Built** | 34 | `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CastSpellByName`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `FindPlayerItemSlot`, `GetBagItem`, `GetEquippedItem`, `GetEquippedItems`, `GetItemIconTexture`, `GetItemIdCooldown`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`, `GetNampowerVersion`, `GetPlayerAuraDuration`, `GetSpellDuration`, `GetSpellIconTexture`, `GetSpellIdForName`, `GetSpellNameAndRankForId`, `GetSpellRecField`, `GetSpellSlotTypeIdForName`, `GetTrinkets`, `GetUnitField`, `GetUnitGUID`, `IsAuraHidden`, `IsSpellInRange`, `IsSpellUsable`, `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `SetMouseoverUnit`, `UseItemIdOrName` |
|
||||
| **Withheld pending grom's say-so** | 8 | `WriteCustomFile`, `ReadCustomFile`, `CustomFileExists`, `ImportFile`, `ExportFile`, `ExecuteCustomLuaFile`, `StartItemExport` (writes a file of its own), `EncryptPassword` + `EncryptedServerLogin` |
|
||||
| **Not ours to add** | 4 | `QueueSpellByName`, `QueueScript`, `ChannelStopCastingNextTick`, `LearnTalentRank` |
|
||||
| **To build, in corpus-call order** | 18 | `GetCurrentCastingInfo` (25 call sites outside the API layer), `GetCastInfo` (20), `GetSpellPower` (7), `GetBagItems` (7), `GetSpellModifiers` (6), `DisenchantAll` (5), `GetSpellIdCooldown` (3), then `GetSpellRec`, `GetTrinketCooldown`, `UseTrinket`, `GetUnitData`, `GetSpellRangeData`, `CombatLogFlush`, `GetRaidTargets`, `SetLocalRaidTargetIndex`, `GetQuestLogQuestIds`, `GetQuestDialogQuestId` (0 each — present for completeness, not need) |
|
||||
| **To build, in corpus-call order** | 16 | `GetSpellPower` (7 call sites outside the API layer), `GetBagItems` (7), `GetSpellModifiers` (6), `DisenchantAll` (5), `GetSpellIdCooldown` (3), then `GetSpellRec`, `GetTrinketCooldown`, `UseTrinket`, `GetUnitData`, `GetSpellRangeData`, `CombatLogFlush`, `GetRaidTargets`, `SetLocalRaidTargetIndex`, `GetQuestLogQuestIds`, `GetQuestDialogQuestId` (0 each — present for completeness, not need) |
|
||||
|
||||
The two at the top of that last bucket are worth the ordering note: `GetCurrentCastingInfo` and
|
||||
`GetCastInfo` are the **most-called nampower functions in the whole corpus**, and one of
|
||||
`GetCurrentCastingInfo`'s sites (`SuperCleveRoidMacros/Core.lua:94`) calls it with no existence
|
||||
guard at all, so on this client that path raises today. They are also coupled: `Core.lua:4899`
|
||||
registers `SPELL_CAST_EVENT` **gated on `GetCurrentCastingInfo` existing**, so defining the function
|
||||
without firing the event would move that addon from "never registered" to "registered and silent",
|
||||
which is worse. Implement the pair with the event.
|
||||
The two that were at the head of that last bucket are now built — see "The cast feed" above for
|
||||
why they came first and what each decision cost.
|
||||
|
||||
### The event table, accounted for
|
||||
|
||||
@@ -629,9 +665,9 @@ from the consumer side — it destructures `arg2`=spellId, `arg3`=casterGuid, `a
|
||||
|
||||
| Bucket | Names |
|
||||
|---|---|
|
||||
| **Fired** | `BUFF_ADDED_SELF`, `BUFF_REMOVED_SELF`, `DEBUFF_ADDED_SELF`, `DEBUFF_REMOVED_SELF`, `DEBUFF_ADDED_OTHER`, `DEBUFF_REMOVED_OTHER`, `AURA_CAST_ON_SELF`, `AURA_CAST_ON_OTHER` (`ui_aura.rs`, `ui_cast_events.rs`) |
|
||||
| **Fired** | `BUFF_ADDED_SELF`, `BUFF_REMOVED_SELF`, `DEBUFF_ADDED_SELF`, `DEBUFF_REMOVED_SELF`, `DEBUFF_ADDED_OTHER`, `DEBUFF_REMOVED_OTHER`, `AURA_CAST_ON_SELF`, `AURA_CAST_ON_OTHER` (`ui_aura.rs`, `ui_cast_events.rs`), `SPELL_CAST_EVENT` (`ui_self_cast.rs`) |
|
||||
| **Deliberately absent** | `BUFF_ADDED_OTHER`/`BUFF_REMOVED_OTHER` — diffing the helpful half of every streamed unit, which nothing registers (the `AuraEdge` doc says so at the enum); `SPELL_QUEUE_EVENT` — the queue; `SPELL_DELAYED_OTHER` — upstream's own note is that the server only sends the packet to the affected player, so it never fires there either |
|
||||
| **To fire, off packets already parsed** | `SPELL_START_SELF`/`_OTHER`, `SPELL_GO_SELF`/`_OTHER`, `SPELL_FAILED_SELF`/`_OTHER`, `SPELL_DELAYED_SELF`, `SPELL_CHANNEL_START`, `SPELL_CHANNEL_UPDATE`, `SPELL_CAST_EVENT` — all of these are edges `ui_cast_events.rs`'s one queue already carries for `UNIT_CASTEVENT`; what each needs beyond today's row is named in that file |
|
||||
| **To fire, off packets already parsed** | `SPELL_START_SELF`/`_OTHER`, `SPELL_GO_SELF`/`_OTHER`, `SPELL_FAILED_SELF`/`_OTHER`, `SPELL_DELAYED_SELF`, `SPELL_CHANNEL_START`, `SPELL_CHANNEL_UPDATE` — all of these are edges `ui_cast_events.rs`'s one queue already carries for `UNIT_CASTEVENT`; what each needs beyond today's row is named in that file |
|
||||
| **To fire, needs its own producer** | `UNIT_DIED`, the thirteen `UNIT_*_GUID`/`PLAYER_GUILD_UPDATE_GUID` rows (one shape: `guid, isPlayer, isTarget, isMouseover, isPet, partyIndex, raidIndex`, gated by the `NP_EnableUnitEvents*` CVars already registered), `UNIT_COMBAT_GUID`, `SPELL_DAMAGE_EVENT_SELF`/`_OTHER`, `SPELL_HEAL_BY_SELF`/`_BY_OTHER`/`_ON_SELF`, `SPELL_ENERGIZE_*`, `SPELL_MISS_SELF`/`_OTHER`, `SPELL_DISPEL_BY_*`, `DAMAGE_SHIELD_*`, `ENVIRONMENTAL_DMG_*`, `AUTO_ATTACK_SELF`/`_OTHER`, `BUFF_UPDATE_DURATION_SELF`, `DEBUFF_UPDATE_DURATION_SELF`, `KEY_DOWN`/`KEY_UP` |
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user