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:
@@ -1035,6 +1035,117 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ── nampower's own cast state: `GetCurrentCastingInfo` and `GetCastInfo` ──────────────────
|
||||
//
|
||||
// The two most-called nampower functions in grom's corpus, and the pair every other cast
|
||||
// question there goes through: `SuperCleveRoidMacros`' `UpdateCastingState()` reads both and
|
||||
// nothing else (`Init.lua:80-118`), and its `[casting]`, `[channeling]`, `[onswing]`,
|
||||
// `[gcd]`/`[nogcd]` macro conditionals read what that function wrote.
|
||||
//
|
||||
// Both answer out of `model.self_cast`, pushed once per tick by `benilla-app`'s
|
||||
// `ui_self_cast.rs` from the reference's own cast globals — none of which is descriptor data,
|
||||
// and none of which 1.12's Lua can reach by any other route.
|
||||
|
||||
// `GetCurrentCastingInfo()` -> `castId, visId, autoId, casting, channeling, onswing,
|
||||
// autoattack`. Seven returns, every one a **number**: the three ids are 0 when nothing is in
|
||||
// that slot and the four flags are 1 or 0, which is the shape every call site destructures
|
||||
// (`local castId, visId, autoId, casting, channeling, onswing, autoattack =
|
||||
// GetCurrentCastingInfo()`, `Init.lua:82`).
|
||||
//
|
||||
// Flags as 0 and not nil, which is the one place this family departs from the `1`-or-nil
|
||||
// house style of `PlayerIsMoving` above: the consumer tests `casting == 1` and
|
||||
// `onswing == 1`, so either spelling works for it — but it also *prints* and compares these,
|
||||
// and upstream returns a number per column because it is pushing the client's own words. Nil
|
||||
// would also make the trailing columns ambiguous, since a trailing nil in Lua is
|
||||
// indistinguishable from an absent return.
|
||||
//
|
||||
// `castId` is a **spell id** despite its name: the inflight slot `0xceca88`. Two independent
|
||||
// call sites read it as one (`Init.lua:89` assigns it to `CurrentSpell.castingSpellId`;
|
||||
// `Core.lua:5427-5433` hands it to `C_Spell.GetSpellName`). `GetCastInfo`'s table below
|
||||
// carries `castId` *and* `spellId` as separate columns, and there that first one really is a
|
||||
// counter — the same upstream name for two different numbers, which is why both are spelled
|
||||
// out here.
|
||||
lua.globals().set(
|
||||
"GetCurrentCastingInfo",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
let c = model.self_cast;
|
||||
Ok((
|
||||
i64::from(c.inflight_spell),
|
||||
i64::from(c.visual_spell),
|
||||
i64::from(c.auto_repeat_spell),
|
||||
i64::from(c.casting),
|
||||
i64::from(c.channeling),
|
||||
i64::from(c.on_swing),
|
||||
i64::from(c.auto_attack),
|
||||
))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `GetCastInfo()` -> one table, or **nil** when no cast or channel is running. Its ten keys
|
||||
// are the DLL's own, read off the string pool in table order (`0xa8854`, see
|
||||
// `docs/ADDON_COMPAT.md`): `castId`, `spellId`, `guid`, `castType`, `castStartS`,
|
||||
// `castEndS`, `castRemainingMs`, `castDurationMs`, `gcdEndS`, `gcdRemainingMs`.
|
||||
//
|
||||
// **nil is the contract, not a shortfall.** `API.IsCasting()` is `GetCastInfo() ~= nil`
|
||||
// (`NampowerAPI.lua:1888`) and `API.GetGCDRemainingMs` falls through to
|
||||
// `GetSpellIdCooldown` when this answers nil, with its own comment saying why ("works after
|
||||
// instant casts"). So an instant's GCD is deliberately *not* reported here: there is no cast,
|
||||
// and inventing a cast-shaped table around a bare GCD would make `IsCasting()` true for a
|
||||
// second and a half after every instant.
|
||||
//
|
||||
// The timing columns come from the player's own cast window — the one
|
||||
// `C_Spell.UnitCastingInfo("player")` reads, folded by `ui_cast_events.rs` off the same
|
||||
// packets — and the identity and GCD columns from this tick's `self_cast`. The feed that
|
||||
// pushes the snapshot declares itself after the drain that folds the window, so the two
|
||||
// halves of this table always describe the same cast.
|
||||
//
|
||||
// `castType` is `3` while the window is a channel and the commit's own type otherwise;
|
||||
// `guid` is the cast's effective target as a GUID token, the zero token when it had none.
|
||||
lua.globals().set(
|
||||
"GetCastInfo",
|
||||
lua.create_function(|lua, ()| {
|
||||
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
|
||||
let model = lua
|
||||
.app_data_ref::<crate::script::Model>()
|
||||
.expect("model app_data");
|
||||
// The cast first, then the channel: the two verbs behind these windows are disjoint,
|
||||
// so at most one of them answers.
|
||||
let Some(w) = model
|
||||
.unit_cast("player", false)
|
||||
.or_else(|| model.unit_cast("player", true))
|
||||
else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let c = model.self_cast;
|
||||
let ms = |secs: f64| (secs * 1000.0).max(0.0).round();
|
||||
let t = lua.create_table()?;
|
||||
t.set("castId", i64::from(c.cast_id))?;
|
||||
t.set("spellId", i64::from(w.spell_id))?;
|
||||
t.set("guid", crate::script::guid_token(c.target_guid))?;
|
||||
t.set("castType", i64::from(c.cast_type))?;
|
||||
t.set("castStartS", w.start)?;
|
||||
t.set("castEndS", w.end)?;
|
||||
t.set("castRemainingMs", ms(w.end - now))?;
|
||||
// The window's own span, pushback included: upstream's `castDurationMs` is the cast's
|
||||
// length as it now stands, and `UnitCastingInfo`'s 11th return reports the pushback
|
||||
// separately for a caller that wants the undisturbed one.
|
||||
t.set("castDurationMs", ms(w.end - w.start))?;
|
||||
t.set("gcdEndS", c.gcd_end)?;
|
||||
t.set(
|
||||
"gcdRemainingMs",
|
||||
if c.gcd_end > 0.0 {
|
||||
ms(c.gcd_end - now)
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
)?;
|
||||
Ok(Value::Table(t))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// `PlayerIsMoving()` / `PlayerIsRooted()` / `PlayerIsSwimming()` (v2.36+): `1` or **nil**, as
|
||||
// upstream's own doc lines say ("Returns 1 if player is moving, nil if stationary"), not a
|
||||
// boolean — `SuperCleveRoidMacros` branches on truthiness, but an addon printing the value
|
||||
|
||||
@@ -129,6 +129,7 @@ mod region_map;
|
||||
mod reputation;
|
||||
mod saved;
|
||||
mod scrollframe;
|
||||
mod self_cast;
|
||||
mod self_movement;
|
||||
mod server_clock;
|
||||
mod session;
|
||||
@@ -245,7 +246,7 @@ pub use model::{
|
||||
SpellProbe, SpellUsable, TextureProbe, TextureSizeProbe,
|
||||
};
|
||||
pub(crate) use model::{Model, PendingTimer};
|
||||
pub use model::{PlayerCacheEntry, SelfMovement, ServerClock};
|
||||
pub use model::{PlayerCacheEntry, SelfCast, SelfMovement, ServerClock};
|
||||
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo};
|
||||
pub use pet::{PetActionView, PetStats};
|
||||
pub use pvp::{HonorState, InspectHonorData};
|
||||
|
||||
@@ -286,6 +286,73 @@ pub struct SelfMovement {
|
||||
pub mouselooking: bool,
|
||||
}
|
||||
|
||||
/// The player's own cast state, pushed once per tick (`benilla-app`'s `ui_self_cast.rs`) for
|
||||
/// nampower's [`GetCurrentCastingInfo`](crate::script::compat) and `GetCastInfo` — the two
|
||||
/// most-called nampower functions in grom's corpus.
|
||||
///
|
||||
/// **Why a snapshot and not a read of `unit_casts`.** The cast *window* (`UnitCastState`) is what
|
||||
/// a bar draws: one row per caster, start and end, nothing about the caster's own machinery. These
|
||||
/// five ids and four flags are the reference's own cast globals — the inflight slot `0xceca88`,
|
||||
/// the cast with the bar `0xceac48`, the auto-repeat key `0xceac30`, the queued strike, the
|
||||
/// melee toggle — which live in `benilla-app`'s resources and have no descriptor field at all.
|
||||
/// `GetCastInfo` then folds the two: its timing columns come from the player's own window, its
|
||||
/// identity and GCD columns from here, pushed in the same frame by a system ordered after the
|
||||
/// window drain so the pair can never describe different casts.
|
||||
///
|
||||
/// `Default` (everything zero) is what a frame before the feed's first run reads, and it is also
|
||||
/// exactly "not casting", which is the honest answer then.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct SelfCast {
|
||||
/// `GetCurrentCastingInfo`'s first return, which upstream spells `castId` and every consumer
|
||||
/// reads as a **spell id**: `SuperCleveRoidMacros` assigns it to `CurrentSpell.castingSpellId`
|
||||
/// (`Init.lua:89`) and hands it to `C_Spell.GetSpellName` (`Core.lua:5427-5433`). It is the
|
||||
/// reference's inflight slot `0xceca88` — the last committed, unresolved cast of any class.
|
||||
pub inflight_spell: u32,
|
||||
/// `visId`: the cast that owns the bar, the reference's `0xceac48`. Read as a spell id too
|
||||
/// (`Core.lua:5347`, the `GetCastInfo` fallback), and `Init.lua:99` stores it as
|
||||
/// `visualSpellId`.
|
||||
pub visual_spell: u32,
|
||||
/// `autoId`: the live auto-repeat spell, the reference's `0xceac30`.
|
||||
pub auto_repeat_spell: u32,
|
||||
/// `casting`: a cast or auto-repeat is in flight. Deliberately **not** the same bool as
|
||||
/// [`Model::casting`](crate::script) — that one answers `SpellStopCasting`'s question (what
|
||||
/// Esc can stop, which includes an auto-repeat and excludes a channel), and upstream's pair
|
||||
/// here is `casting` *and* `channeling` as two independent columns, which is why the consumer
|
||||
/// tests `casting == 1 or channeling == 1`.
|
||||
pub casting: bool,
|
||||
/// `channeling`: a channel is running (`0xceac58`).
|
||||
pub channeling: bool,
|
||||
/// `onswing`: an on-next-swing strike is queued (Heroic Strike, Cleave). Three macro
|
||||
/// conditionals read this column alone (`Conditionals.lua:7587`, `:7594`, `:9120`).
|
||||
pub on_swing: bool,
|
||||
/// `autoattack`: melee auto-attack is running. `Core.lua:94` reads this one with no existence
|
||||
/// guard at all — `local _,_,_,_,_,_,autoattack = GetCurrentCastingInfo()` — so on a client
|
||||
/// without the function that line raises.
|
||||
pub auto_attack: bool,
|
||||
/// `GetCastInfo`'s `castId`, which is a **different** number from
|
||||
/// [`Self::inflight_spell`](Self) despite the shared upstream name: that table carries
|
||||
/// `castId` and `spellId` as separate columns, so this one is the monotonic count of
|
||||
/// committed casts (`PendingCast::arm`'s own counter), not a spell id.
|
||||
pub cast_id: u32,
|
||||
/// `GetCastInfo`'s `castType`, decided at the commit: `0` normal, `1` non-GCD, `2` on-swing,
|
||||
/// `3` channel, `4` targeting, `5` targeting non-GCD. The channel value is applied by the
|
||||
/// binding from the live window rather than stored here, because a cast's type is fixed at
|
||||
/// send and whether it is *currently* channelling is not.
|
||||
pub cast_type: u8,
|
||||
/// `GetCastInfo`'s `guid`: the committed cast's target. Written as a GUID token by the
|
||||
/// binding, `0` meaning no explicit target.
|
||||
pub target_guid: u64,
|
||||
/// The running global cooldown on the `GetTime()` clock — `GetCastInfo`'s `gcdEndS`, with
|
||||
/// `gcdRemainingMs` derived from it. `0.0` for both when no GCD is running.
|
||||
///
|
||||
/// One number for what 1.12 keys per `startRecoveryCategory`, because that is the shape the
|
||||
/// consumer wants: `API.GetGCDRemainingMs` divides it and the `[gcd]`/`[nogcd]` macro
|
||||
/// conditionals compare it against a number of seconds. The app publishes the **longest**
|
||||
/// running GCD leg, which is the one a player is actually waiting on.
|
||||
pub gcd_start: f64,
|
||||
pub gcd_end: f64,
|
||||
}
|
||||
|
||||
/// Minted id to handle, indexed directly: ids are dense and every scripted call looks one up.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct IdMap<T>(Vec<Option<T>>);
|
||||
@@ -1331,6 +1398,8 @@ pub(crate) struct Model {
|
||||
/// lowercase-initial siblings pending): not 1.12 verbs, so fed only for `"player"`, not the
|
||||
/// generic per-token reach the rest of `UnitState` has.
|
||||
pub(crate) self_movement: SelfMovement,
|
||||
/// The player's own cast machinery, pushed once per tick beside it.
|
||||
pub(crate) self_cast: SelfCast,
|
||||
|
||||
/// A `Minimap:PingLocation(x, y)`, drained the same frame: centre-relative offsets in UI units,
|
||||
/// `GetCursorPosition`'s space, not the window pixels of the app's minimap geometry.
|
||||
@@ -1998,6 +2067,7 @@ impl Model {
|
||||
timers_cancelled: Vec::new(),
|
||||
next_timer_id: 0,
|
||||
self_movement: SelfMovement::default(),
|
||||
self_cast: SelfCast::default(),
|
||||
minimap_ping_request: None,
|
||||
minimap_ping: (0.0, 0.0),
|
||||
chat_sends: Vec::new(),
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
//! The self player's own cast machinery ([`SelfCast`]): the reference's cast globals, which live
|
||||
//! in `benilla-app`'s resources and have no descriptor field, pushed once per tick for nampower's
|
||||
//! `GetCurrentCastingInfo` and `GetCastInfo` (`compat/nampower.rs`).
|
||||
|
||||
use super::SelfCast;
|
||||
|
||||
impl super::UiScript {
|
||||
/// Push this tick's self-cast snapshot.
|
||||
pub fn set_self_cast(&mut self, cast: SelfCast) {
|
||||
self.model_mut().self_cast = cast;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ mod regions;
|
||||
mod resize_bounds;
|
||||
mod runaway_frame;
|
||||
mod scrollframe;
|
||||
mod self_cast;
|
||||
mod setparent;
|
||||
mod simplehtml;
|
||||
mod size_changed;
|
||||
|
||||
@@ -350,6 +350,19 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
|
||||
// from the same predicate `benilla-app`'s `ui_aura::shown_in_aura_ui` applies to the
|
||||
// lists, so it cannot contradict the aura list an addon reads beside it.
|
||||
"IsAuraHidden",
|
||||
// nampower's own cast state (same file), the pair every other cast question in grom's
|
||||
// corpus goes through — 25 and 20 call sites outside the API wrapper, which makes them
|
||||
// the most-called nampower functions installed here.
|
||||
//
|
||||
// 1.12 has no spelling for either and cannot grow one: what they report is the client's
|
||||
// own cast globals (the inflight slot, the cast that owns the bar, the auto-repeat key,
|
||||
// the queued strike, the melee attack lock, the global cooldown's end), and that client
|
||||
// publishes *events* about its cast — `SPELLCAST_START`, `SPELLCAST_STOP` — and not one
|
||||
// getter that answers what is in flight. `GetCastInfo` folds the same timing a stock
|
||||
// cast bar draws from `SPELLCAST_START`'s own arguments, which is why its table cannot
|
||||
// disagree with the bar: both come off this frame's one cast window.
|
||||
"GetCurrentCastingInfo",
|
||||
"GetCastInfo",
|
||||
// ClassicAPI's Enum (script/compat/classic_api/enums.rs): the modern client's named
|
||||
// constants, so an addon can write `Enum.PowerType.Mana` for 0. Pure data, no 1.12
|
||||
// counterpart — 1.12 spells the same numbers as bare literals or `LE_*`/`ITEM_QUALITY_*`
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
//! nampower's own cast state: `GetCurrentCastingInfo` and `GetCastInfo`
|
||||
//! (`compat/nampower.rs`, fed by `benilla-app`'s `ui_self_cast.rs`).
|
||||
//!
|
||||
//! Same approach as `cast_info.rs` next door, and for the same reason: a positional return shape
|
||||
//! and a table of ten keys are only correct relative to the code that reads them, so the test is
|
||||
//! that code — **`SuperCleveRoidMacros`' own bodies, transcribed**. Where the app-side arithmetic
|
||||
//! is pinned (which commit sets which `castType`, when the snapshot is pushed) is
|
||||
//! `benilla-app`'s `ui_self_cast.rs`; what this file pins is the half that lives here.
|
||||
//!
|
||||
//! The bodies, by line (`benilla-config/AddOns/SuperCleveRoidMacros/`):
|
||||
//! - `NampowerAPI.lua:1862-1885` `API.GetCastInfo`, including the `GetCurrentCastingInfo`
|
||||
//! fallback it keeps for older nampower — which is itself a second reader of the seven returns.
|
||||
//! - `NampowerAPI.lua:1888-1892` `API.IsCasting`, which is `GetCastInfo() ~= nil` and nothing
|
||||
//! else, and so is the one test that says what nil has to mean.
|
||||
//! - `NampowerAPI.lua:1898-1921` `API.GetGCDRemainingMs` and `:1926-1932`
|
||||
//! `API.GetCastRemainingMs`, the two that divide these columns.
|
||||
//! - `Init.lua:79-118` `CleveRoids.UpdateCastingState`, which reads both verbs and is where every
|
||||
//! `[casting]`/`[channeling]`/`[onswing]` macro conditional gets its answer.
|
||||
|
||||
use crate::script::{SelfCast, UiScript, UnitCastState, UnitState};
|
||||
|
||||
/// Frostbolt's real id, as `cast_info.rs` uses it.
|
||||
const FROSTBOLT: u32 = 116;
|
||||
|
||||
/// A VM with `GetTime()` parked at 100 s and `"player"` naming one GUID — the key every window is
|
||||
/// found by.
|
||||
fn harness() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_now(100.0);
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(UnitState {
|
||||
exists: true,
|
||||
has_object: true,
|
||||
guid: 0x2a,
|
||||
name: Some("Tester".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
fn window(spell_id: u32, start: f64, end: f64, channel: bool) -> UnitCastState {
|
||||
UnitCastState {
|
||||
guid: 0x2a,
|
||||
spell_id,
|
||||
start,
|
||||
end,
|
||||
channel,
|
||||
delay_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `NampowerAPI.lua:1862-1932`, the four wrappers transcribed. `API.features.*` is replaced by the
|
||||
/// plain `_G` test `HasFeature` reduces to once the version requirement is met, which it is: we
|
||||
/// report 4.1.3 and these need 2.18 and 2.0.
|
||||
const NAMPOWER_API: &str = r#"
|
||||
API = {}
|
||||
function API.GetCastInfo()
|
||||
if GetCastInfo then
|
||||
local ok, result = pcall(GetCastInfo)
|
||||
if ok then return result end
|
||||
end
|
||||
if GetCurrentCastingInfo then
|
||||
local castId, visId, autoId, casting, channeling, onswing, autoattack = GetCurrentCastingInfo()
|
||||
if castId and castId > 0 then
|
||||
return { spellId = castId, castType = channeling == 1 and 3 or 0 }
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
function API.IsCasting()
|
||||
local info = API.GetCastInfo()
|
||||
return info ~= nil
|
||||
end
|
||||
function API.GetGCDRemainingMs()
|
||||
local info = API.GetCastInfo()
|
||||
if info and info.gcdRemainingMs and info.gcdRemainingMs > 0 then
|
||||
return info.gcdRemainingMs
|
||||
end
|
||||
return 0
|
||||
end
|
||||
function API.GetCastRemainingMs()
|
||||
local info = API.GetCastInfo()
|
||||
if info and info.castRemainingMs then return info.castRemainingMs end
|
||||
return 0
|
||||
end
|
||||
"#;
|
||||
|
||||
/// `Init.lua:79-118`, the state machine every macro conditional reads, with its own comments kept
|
||||
/// where they state a rule this has to honour.
|
||||
const UPDATE_CASTING_STATE: &str = r#"
|
||||
CurrentSpell = { type = "", castingSpellId = nil }
|
||||
function UpdateCastingState()
|
||||
if not GetCurrentCastingInfo then return false end
|
||||
local castId, visId, autoId, casting, channeling, onswing, autoattack = GetCurrentCastingInfo()
|
||||
if casting == 1 then
|
||||
CurrentSpell.type = "cast"
|
||||
CurrentSpell.castingSpellId = castId
|
||||
elseif CurrentSpell.type == "cast" then
|
||||
CurrentSpell.type = ""
|
||||
CurrentSpell.castingSpellId = nil
|
||||
end
|
||||
CurrentSpell.autoAttack = (autoattack == 1)
|
||||
CurrentSpell.onSwingPending = (onswing == 1)
|
||||
CurrentSpell.visualSpellId = visId
|
||||
CurrentSpell.autoRepeatSpellId = autoId
|
||||
if GetCastInfo then
|
||||
local ok, info = pcall(GetCastInfo)
|
||||
if ok and info then
|
||||
CurrentSpell.castRemainingMs = info.castRemainingMs
|
||||
CurrentSpell.castEndTime = info.castEndS
|
||||
CurrentSpell.gcdRemainingMs = info.gcdRemainingMs
|
||||
CurrentSpell.gcdEndTime = info.gcdEndS
|
||||
else
|
||||
CurrentSpell.castRemainingMs = nil
|
||||
CurrentSpell.castEndTime = nil
|
||||
CurrentSpell.gcdRemainingMs = nil
|
||||
CurrentSpell.gcdEndTime = nil
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
"#;
|
||||
|
||||
/// The seven returns, in the shape `Init.lua:82` destructures: every one a number, the three ids
|
||||
/// `0` when their slot is empty and the four flags `1`/`0`.
|
||||
///
|
||||
/// `casting` and `channeling` are **independent** columns, which `Core.lua:304-311` relies on
|
||||
/// (`if (casting == 1 or channeling == 1) and ...`): a channel must light the second alone, or a
|
||||
/// consumer that branches on `casting` first would treat a channel as an ordinary cast.
|
||||
#[test]
|
||||
fn get_current_casting_info_answers_seven_numbers() {
|
||||
let mut s = harness();
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, i64, i64, i64, i64, i64)>("return GetCurrentCastingInfo()")
|
||||
.unwrap(),
|
||||
(0, 0, 0, 0, 0, 0, 0),
|
||||
"nothing in flight: zeros, not nils — a trailing nil is indistinguishable from an \
|
||||
absent return, which would make the last columns unreadable"
|
||||
);
|
||||
|
||||
s.set_self_cast(SelfCast {
|
||||
inflight_spell: FROSTBOLT,
|
||||
visual_spell: FROSTBOLT,
|
||||
auto_repeat_spell: 0,
|
||||
casting: true,
|
||||
channeling: false,
|
||||
on_swing: false,
|
||||
auto_attack: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, i64, i64, i64, i64, i64)>("return GetCurrentCastingInfo()")
|
||||
.unwrap(),
|
||||
(116, 116, 0, 1, 0, 0, 1)
|
||||
);
|
||||
|
||||
// `Init.lua`'s own reading of it: `castId` is a **spell id**, and the two metadata flags are
|
||||
// stored as Lua booleans off an `== 1` test.
|
||||
s.run(UPDATE_CASTING_STATE).unwrap();
|
||||
assert!(s.eval::<bool>("return UpdateCastingState()").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<(String, i64, bool, bool)>(
|
||||
"return CurrentSpell.type, CurrentSpell.castingSpellId, \
|
||||
CurrentSpell.autoAttack, CurrentSpell.onSwingPending"
|
||||
)
|
||||
.unwrap(),
|
||||
("cast".to_string(), 116, true, false)
|
||||
);
|
||||
|
||||
// A channel lights `channeling` alone, and the consumer's `casting == 1` branch therefore
|
||||
// clears the cast state it had — which is exactly what `Init.lua`'s `elseif` is for.
|
||||
s.set_self_cast(SelfCast {
|
||||
channeling: true,
|
||||
on_swing: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>(
|
||||
"local _, _, _, casting, channeling = GetCurrentCastingInfo() \
|
||||
return casting, channeling"
|
||||
)
|
||||
.unwrap(),
|
||||
(0, 1)
|
||||
);
|
||||
assert!(s.eval::<bool>("return UpdateCastingState()").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<(String, bool)>("return CurrentSpell.type, CurrentSpell.onSwingPending")
|
||||
.unwrap(),
|
||||
(String::new(), true)
|
||||
);
|
||||
}
|
||||
|
||||
/// `GetCastInfo`'s ten keys, their units, and the one rule the whole family rests on: **nil when
|
||||
/// nothing is casting**. `API.IsCasting()` is that nil and nothing else.
|
||||
#[test]
|
||||
fn get_cast_info_is_nil_until_a_window_opens_then_carries_ten_keys() {
|
||||
let mut s = harness();
|
||||
s.run(NAMPOWER_API).unwrap();
|
||||
|
||||
// No window: nil from the native verb, and the fallback finds nothing either, so `IsCasting`
|
||||
// is false and both divisors are 0 rather than an error.
|
||||
assert!(s
|
||||
.eval::<Option<mlua::Value>>("return GetCastInfo()")
|
||||
.unwrap()
|
||||
.is_none_or(|v| v.is_nil()));
|
||||
assert!(!s.eval::<bool>("return API.IsCasting()").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return API.GetGCDRemainingMs()").unwrap(),
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return API.GetCastRemainingMs()").unwrap(),
|
||||
0.0
|
||||
);
|
||||
|
||||
// A 3 s Frostbolt a third of the way through, with the 1.5 s GCD armed at its send, and the
|
||||
// commit's own columns beside it.
|
||||
s.set_unit_casts(vec![window(FROSTBOLT, 99.0, 102.0, false)]);
|
||||
s.set_self_cast(SelfCast {
|
||||
inflight_spell: FROSTBOLT,
|
||||
visual_spell: FROSTBOLT,
|
||||
casting: true,
|
||||
cast_id: 7,
|
||||
cast_type: 0,
|
||||
target_guid: 0x63,
|
||||
gcd_start: 99.0,
|
||||
gcd_end: 100.5,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
assert!(s.eval::<bool>("return API.IsCasting()").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, String, i64)>(
|
||||
"local i = GetCastInfo() return i.castId, i.spellId, i.guid, i.castType"
|
||||
)
|
||||
.unwrap(),
|
||||
(7, 116, "0x0000000000000063".to_string(), 0),
|
||||
"castId is a counter and spellId a spell — the same upstream name for two numbers, so \
|
||||
both are asserted; guid is the cast's target in the one GUID spelling every compat verb \
|
||||
writes"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, f64, f64)>(
|
||||
"local i = GetCastInfo() \
|
||||
return i.castStartS, i.castEndS, i.castRemainingMs, i.castDurationMs"
|
||||
)
|
||||
.unwrap(),
|
||||
(99.0, 102.0, 2000.0, 3000.0),
|
||||
"the two S columns are GetTime() seconds and the two Ms columns milliseconds, which is \
|
||||
the mixed-unit table upstream really hands back"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>("local i = GetCastInfo() return i.gcdEndS, i.gcdRemainingMs")
|
||||
.unwrap(),
|
||||
(100.5, 500.0)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return API.GetGCDRemainingMs()").unwrap(),
|
||||
500.0,
|
||||
"the wrapper's fast path, which is what the [gcd] macro conditional divides by 1000"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return API.GetCastRemainingMs()").unwrap(),
|
||||
2000.0
|
||||
);
|
||||
|
||||
// A lapsed window still answers — the drain prunes it, not this verb — and the remainders
|
||||
// floor at zero rather than going negative, which a consumer comparing `> 0` depends on.
|
||||
s.set_now(103.0);
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64)>("local i = GetCastInfo() return i.castRemainingMs, i.gcdRemainingMs")
|
||||
.unwrap(),
|
||||
(0.0, 0.0)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return API.GetGCDRemainingMs()").unwrap(),
|
||||
0.0
|
||||
);
|
||||
}
|
||||
|
||||
/// A channel answers `GetCastInfo` too — `castType` 3 — where it answers
|
||||
/// `C_Spell.UnitCastingInfo` not at all. The two families differ on purpose: ClassicAPI's pair is
|
||||
/// disjoint by design (a channel is `UnitChannelInfo`'s), and nampower has one getter for both.
|
||||
#[test]
|
||||
fn a_channel_answers_get_cast_info_as_type_three() {
|
||||
let mut s = harness();
|
||||
s.set_unit_casts(vec![window(15_407, 100.0, 103.0, true)]);
|
||||
s.set_self_cast(SelfCast {
|
||||
channeling: true,
|
||||
cast_id: 2,
|
||||
cast_type: 3,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, f64)>(
|
||||
"local i = GetCastInfo() return i.spellId, i.castType, i.castDurationMs"
|
||||
)
|
||||
.unwrap(),
|
||||
(15_407, 3, 3000.0)
|
||||
);
|
||||
// And the ClassicAPI pair still splits them the way its own consumers assume.
|
||||
assert!(s
|
||||
.eval::<Option<String>>(r#"local n = C_Spell.UnitCastingInfo("player") return n"#)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
/// The GCD when **no** cast is running is deliberately not reported: there is no window, so
|
||||
/// `GetCastInfo` is nil, so `API.IsCasting()` is false and `API.GetGCDRemainingMs` falls through
|
||||
/// to its `GetSpellIdCooldown` path — whose own comment says it is the one that "works after
|
||||
/// instant casts".
|
||||
///
|
||||
/// Pinning this because the tempting shortcut is the opposite: answer a table whenever the GCD is
|
||||
/// up, so the fast path always hits. That would make `IsCasting()` true for a second and a half
|
||||
/// after every instant cast, and `[casting]` is a macro conditional players gate abilities on.
|
||||
#[test]
|
||||
fn an_instants_gcd_alone_does_not_make_a_cast() {
|
||||
let mut s = harness();
|
||||
s.run(NAMPOWER_API).unwrap();
|
||||
s.set_self_cast(SelfCast {
|
||||
cast_id: 9,
|
||||
gcd_start: 100.0,
|
||||
gcd_end: 101.5,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(s
|
||||
.eval::<Option<mlua::Value>>("return GetCastInfo()")
|
||||
.unwrap()
|
||||
.is_none_or(|v| v.is_nil()));
|
||||
assert!(!s.eval::<bool>("return API.IsCasting()").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return API.GetGCDRemainingMs()").unwrap(),
|
||||
0.0,
|
||||
"0 from the wrapper's own `return 0` tail, which is its signal to try the other path"
|
||||
);
|
||||
}
|
||||
|
||||
/// The fallback arm of `API.GetCastInfo` is a second reader of `GetCurrentCastingInfo`'s returns,
|
||||
/// and it reads the first one as a **spell id** (`spellId = castId`). It only runs when the native
|
||||
/// verb is absent, so the test removes it — which is also the one case where `castId > 0` has to
|
||||
/// mean "something is casting".
|
||||
#[test]
|
||||
fn the_wrappers_fallback_arm_reads_the_first_return_as_a_spell_id() {
|
||||
let mut s = harness();
|
||||
s.run(NAMPOWER_API).unwrap();
|
||||
s.run("GetCastInfo = nil").unwrap();
|
||||
s.set_self_cast(SelfCast {
|
||||
inflight_spell: FROSTBOLT,
|
||||
casting: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("local i = API.GetCastInfo() return i.spellId, i.castType")
|
||||
.unwrap(),
|
||||
(116, 0)
|
||||
);
|
||||
s.set_self_cast(SelfCast {
|
||||
inflight_spell: 15_407,
|
||||
channeling: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return API.GetCastInfo().castType").unwrap(),
|
||||
3,
|
||||
"the fallback derives type 3 from the channeling flag, which is why that column has to \
|
||||
be a 1 and not a Lua true"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user