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
@@ -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"
);
}