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:
2026-10-24 07:16:30 -06:00
co-authored by Claude Opus 5
parent aab2130c62
commit ecee994ab6
19 changed files with 1252 additions and 31 deletions
+2
View File
@@ -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)
+1
View File
@@ -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;
+58 -1
View File
@@ -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>>();
+79 -5
View File
@@ -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();
+28 -9
View File
@@ -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>>();
+1 -1
View File
@@ -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>,
+2
View File
@@ -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",
+438
View File
@@ -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
+2 -1
View File
@@ -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};
+70
View File
@@ -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(),
+12
View File
@@ -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
View File
@@ -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` |