From ecee994ab6537bee533a9ab8939f6d1dbd5e18fd Mon Sep 17 00:00:00 2001 From: grom Date: Sat, 24 Oct 2026 07:16:30 -0600 Subject: [PATCH] app+ui: the player's own cast state, and SPELL_CAST_EVENT off the commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/src/game_plugins.rs | 2 + crates/benilla-app/src/lib.rs | 1 + crates/benilla-app/src/spell/cast_send.rs | 59 ++- crates/benilla-app/src/spell/cooldowns.rs | 84 +++- crates/benilla-app/src/spell/inflight.rs | 37 +- crates/benilla-app/src/spell/targeting/mod.rs | 1 + .../benilla-app/src/spell/targeting/world.rs | 1 + crates/benilla-app/src/ui_cast_events.rs | 2 +- crates/benilla-app/src/ui_items/drain.rs | 2 + .../benilla-app/src/ui_script/reference_ui.rs | 12 + crates/benilla-app/src/ui_self_cast.rs | 438 ++++++++++++++++++ .../benilla-ui/src/script/compat/nampower.rs | 111 +++++ crates/benilla-ui/src/script/mod.rs | 3 +- crates/benilla-ui/src/script/model.rs | 70 +++ crates/benilla-ui/src/script/self_cast.rs | 12 + crates/benilla-ui/src/script/tests/mod.rs | 1 + .../src/script/tests/reference_surface.rs | 13 + .../benilla-ui/src/script/tests/self_cast.rs | 370 +++++++++++++++ docs/ADDON_COMPAT.md | 64 ++- 19 files changed, 1252 insertions(+), 31 deletions(-) create mode 100644 crates/benilla-app/src/ui_self_cast.rs create mode 100644 crates/benilla-ui/src/script/self_cast.rs create mode 100644 crates/benilla-ui/src/script/tests/self_cast.rs diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index b7e1e910..d0967ede 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -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) diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 5db8cc29..8ac25013 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -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; diff --git a/crates/benilla-app/src/spell/cast_send.rs b/crates/benilla-app/src/spell/cast_send.rs index a236da1e..531ec7c1 100644 --- a/crates/benilla-app/src/spell/cast_send.rs +++ b/crates/benilla-app/src/spell/cast_send.rs @@ -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::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.init_resource::(); // A bare World has no message storage until it is asked for. world.init_resource::>(); diff --git a/crates/benilla-app/src/spell/cooldowns.rs b/crates/benilla-app/src/spell/cooldowns.rs index 709dd174..b75847b7 100644 --- a/crates/benilla-app/src/spell/cooldowns.rs +++ b/crates/benilla-app/src/spell/cooldowns.rs @@ -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(); diff --git a/crates/benilla-app/src/spell/inflight.rs b/crates/benilla-app/src/spell/inflight.rs index 7922fd05..dfeed35b 100644 --- a/crates/benilla-app/src/spell/inflight.rs +++ b/crates/benilla-app/src/spell/inflight.rs @@ -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); +pub(crate) struct PendingCast { + state: Option, + /// 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 { - 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 { - 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; } } } diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index 5529d2d1..fd8abd21 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -512,6 +512,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::>(); world.init_resource::(); diff --git a/crates/benilla-app/src/spell/targeting/world.rs b/crates/benilla-app/src/spell/targeting/world.rs index cef9281a..e826da65 100644 --- a/crates/benilla-app/src/spell/targeting/world.rs +++ b/crates/benilla-app/src/spell/targeting/world.rs @@ -148,6 +148,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.init_resource::(); world.init_resource::>(); world.init_resource::>(); diff --git a/crates/benilla-app/src/ui_cast_events.rs b/crates/benilla-app/src/ui_cast_events.rs index 9fd898bf..1f6e8f7f 100644 --- a/crates/benilla-app/src/ui_cast_events.rs +++ b/crates/benilla-app/src/ui_cast_events.rs @@ -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>, mut feed: ResMut, mut windows: ResMut, diff --git a/crates/benilla-app/src/ui_items/drain.rs b/crates/benilla-app/src/ui_items/drain.rs index 0de69c70..55fdffe6 100644 --- a/crates/benilla-app/src/ui_items/drain.rs +++ b/crates/benilla-app/src/ui_items/drain.rs @@ -750,6 +750,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -1705,6 +1706,7 @@ mod bind_confirm_tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index 001d51b2..d027be17 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -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", diff --git a/crates/benilla-app/src/ui_self_cast.rs b/crates/benilla-app/src/ui_self_cast.rs new file mode 100644 index 00000000..2122b9e3 --- /dev/null +++ b/crates/benilla-app/src/ui_self_cast.rs @@ -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, + last: Option, +} + +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>, + clock: Res, + mut sends: ResMut, + pending: Res, + queued_melee: Res, + channel: Res, + auto_repeat: Res, + cooldowns: Res, + self_guid: Res, + index: Res, + casting: Query<&crate::creature_anim::Casting>, + engaged: Query<(), (With, With)>, +) { + 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::().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::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::(); + 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::() + .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::() + .arm(133, now, 3_000, true); + app.world_mut().resource_mut::().arm(78); + app.world_mut().resource_mut::().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::() + .eval::("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::().clear_if(133); + app.world_mut().resource_mut::().0 = None; + app.world_mut() + .resource_mut::() + .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::(); + 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::() + .start_gcd(133, &spell, now); + app.world_mut().run_system_once(feed_self_cast).unwrap(); + + let (end, left) = app + .world() + .non_send_resource::() + .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::() + .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::() + .arm(133, Instant::now(), 3_000, true); + app.world_mut() + .resource_mut::() + .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 = app + .world() + .non_send_resource::() + .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 = app + .world() + .non_send_resource::() + .eval("return seen") + .unwrap(); + assert_eq!(seen.len(), 1, "one commit, one event"); + assert_eq!( + app.world().resource::().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::(); + app.world_mut() + .resource_mut::() + .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::(); + assert!(sends.queue.is_empty()); + assert_eq!(sends.cast_type_for(133), cast_type::ON_SWING); + } +} diff --git a/crates/benilla-ui/src/script/compat/nampower.rs b/crates/benilla-ui/src/script/compat/nampower.rs index 095b67ad..d942d151 100644 --- a/crates/benilla-ui/src/script/compat/nampower.rs +++ b/crates/benilla-ui/src/script/compat/nampower.rs @@ -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::() + .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::() + .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 diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 28b8a030..3b754996 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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}; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index bf6209bf..45311da1 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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(Vec>); @@ -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(), diff --git a/crates/benilla-ui/src/script/self_cast.rs b/crates/benilla-ui/src/script/self_cast.rs new file mode 100644 index 00000000..f1f09563 --- /dev/null +++ b/crates/benilla-ui/src/script/self_cast.rs @@ -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; + } +} diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index 28d191ea..e9474f7e 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -41,6 +41,7 @@ mod regions; mod resize_bounds; mod runaway_frame; mod scrollframe; +mod self_cast; mod setparent; mod simplehtml; mod size_changed; diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 03361efb..7ab02366 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -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_*` diff --git a/crates/benilla-ui/src/script/tests/self_cast.rs b/crates/benilla-ui/src/script/tests/self_cast.rs new file mode 100644 index 00000000..501039ea --- /dev/null +++ b/crates/benilla-ui/src/script/tests/self_cast.rs @@ -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::("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::("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::>("return GetCastInfo()") + .unwrap() + .is_none_or(|v| v.is_nil())); + assert!(!s.eval::("return API.IsCasting()").unwrap()); + assert_eq!( + s.eval::("return API.GetGCDRemainingMs()").unwrap(), + 0.0 + ); + assert_eq!( + s.eval::("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::("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::("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::("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::("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::>(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::>("return GetCastInfo()") + .unwrap() + .is_none_or(|v| v.is_nil())); + assert!(!s.eval::("return API.IsCasting()").unwrap()); + assert_eq!( + s.eval::("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::("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" + ); +} diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 15f3010d..1ca2cfb4 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -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` |