diff --git a/crates/benilla-ui/src/script/compat/classic_api/spell.rs b/crates/benilla-ui/src/script/compat/classic_api/spell.rs
index 2746a7bc..91a47c99 100644
--- a/crates/benilla-ui/src/script/compat/classic_api/spell.rs
+++ b/crates/benilla-ui/src/script/compat/classic_api/spell.rs
@@ -415,22 +415,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result
{
})?,
)?;
- // IsSpellUsable(spellID): approximated as "known and not on cooldown" — modern usability also
- // weighs resource cost (mana/rage/energy) and range, neither tracked per spell id here.
+ // IsSpellUsable(spellID|name) -> isUsable, insufficientPower, off the live per-spell map
+ // `ui_action::state` pushes: the reference's own usable walk (`0x6e3d60`), the same answer the
+ // action bar greys its buttons with. Upstream's own list of what makes a spell unusable is
+ // "hasn't learned it, lacks mana or reagents, reactive conditions haven't been met" — not
+ // cooldown, and the walk agrees ("a running cooldown never greys here",
+ // `spell/usable.rs` leg 11), so `GetSpellCooldown` stays the question about cooldowns.
+ //
+ // This was approximated as "known and not on cooldown", which is the one thing upstream does
+ // *not* count and none of the things it does. For a reactive ability it inverted the answer:
+ // Overpower greys on the caster's combo-point byte (leg 5, `PLAYER_FIELD_BYTES` byte 1 — the
+ // byte the server sets when the proc banks), so the walk answers it only inside the proc
+ // window, while "off cooldown" showed it nearly always.
+ //
+ // pfUI's reactive indicator is the caller and it passes a **name**:
+ // `C_Spell.IsSpellUsable(icon.spellName)` (`modules/nampower.lua:130`, for Overpower, Revenge,
+ // Execute, Riposte, Mongoose Bite and Counterattack), against a signature of `u32` that raised
+ // on it once per frame while the indicator was switched on. It hands the answer straight to
+ // `SetShown`, so both returns stay booleans as upstream's are — a numeric 0 is truthy in Lua
+ // and would show every icon forever.
t.set(
"IsSpellUsable",
- lua.create_function(|lua, spell_id: u32| {
+ lua.create_function(|lua, ident: Option| {
let model = lua.app_data_ref::().expect("model app_data");
- let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
- let Some(s) =
- find_slot(&model, spell_id).and_then(|(slot, bank)| book_slot(&model, slot, bank))
- else {
- return Ok(false);
+ let Some(id) = super::super::nampower::spell_ident(&model, ident.as_ref()) else {
+ return Ok((false, false));
};
- let off_cooldown = s.cooldown.is_none_or(|(start_ms, duration_ms, enabled)| {
- !enabled || (start_ms as f64 / 1000.0) + f64::from(duration_ms) / 1000.0 <= now
- });
- Ok(off_cooldown)
+ Ok(model
+ .spell_usable
+ .get(&id)
+ .map_or((false, false), |u| (u.usable, u.out_of_power)))
})?,
)?;
@@ -694,6 +708,63 @@ pub(super) fn install_globals(lua: &Lua, t: &Table) -> mlua::Result<()> {
mod tests {
use crate::script::{SpellBookState, SpellSlotView, SpellTabView, UiScript};
+ /// `C_Spell.IsSpellUsable` answers the usable walk rather than "off cooldown", and takes the
+ /// name pfUI's reactive indicator passes it (`modules/nampower.lua:130`). Overpower is the case
+ /// that separates the two: the walk greys it outside its proc window, which is the whole point
+ /// of a reactive indicator, while "off cooldown" showed it nearly always.
+ #[test]
+ fn is_spell_usable_reads_the_usable_walk_and_takes_a_name() {
+ let mut s = UiScript::new().unwrap();
+ let slot = |spell_id: u32, name: &str| SpellSlotView {
+ spell_id,
+ name: name.into(),
+ rank: Some("Rank 1".into()),
+ ..Default::default()
+ };
+ s.set_spellbook(SpellBookState {
+ tabs: Vec::new(),
+ slots: vec![slot(7384, "Overpower"), slot(6572, "Revenge")],
+ });
+ let u = |usable, out_of_power| crate::script::SpellUsable {
+ usable,
+ out_of_power,
+ in_range: None,
+ };
+ // Outside the proc window, and on no cooldown at all.
+ s.set_spell_usable(
+ [(7384, u(false, false)), (6572, u(false, true))]
+ .into_iter()
+ .collect(),
+ );
+ assert!(!s
+ .eval::("return C_Spell.IsSpellUsable(7384)")
+ .unwrap());
+ assert!(
+ !s.eval::("return C_Spell.IsSpellUsable(\"Overpower\")")
+ .unwrap(),
+ "the caller passes a name, and it used to raise on one"
+ );
+ let pair: String = s
+ .eval(
+ "local a, b = C_Spell.IsSpellUsable(6572) \
+ return tostring(a) .. \"|\" .. tostring(b)",
+ )
+ .unwrap();
+ assert_eq!(
+ pair, "false|true",
+ "both returns are booleans, which is what `SetShown` needs"
+ );
+ // The proc banks: the walk flips, with nothing about cooldowns changing.
+ s.set_spell_usable([(7384, u(true, false))].into_iter().collect());
+ assert!(s
+ .eval::("return C_Spell.IsSpellUsable(\"Overpower\")")
+ .unwrap());
+ // A spell the book does not carry answers false rather than raising.
+ assert!(!s
+ .eval::("return C_Spell.IsSpellUsable(\"Mongoose Bite\")")
+ .unwrap());
+ }
+
/// `GetSpellMechanicByID`'s three-way answer, which is the consumer's own spec: nothing for an
/// `GetSpellEffectMechanics` reads the **per-effect** column, which is a different question
/// from its spell-level sibling and the reason the consumer calls both: vanilla stores some
diff --git a/crates/benilla-ui/src/script/compat/nampower.rs b/crates/benilla-ui/src/script/compat/nampower.rs
index 26a60ed4..936f71aa 100644
--- a/crates/benilla-ui/src/script/compat/nampower.rs
+++ b/crates/benilla-ui/src/script/compat/nampower.rs
@@ -76,7 +76,7 @@ fn book_slots(
/// nampower's `spellIdentifier`: a spell id, or a name resolved through the player's own book at
/// its highest known rank (the same rule [`GetSpellIdForName`] follows).
-fn spell_ident(model: &crate::script::Model, v: Option<&mlua::Value>) -> Option {
+pub(super) fn spell_ident(model: &crate::script::Model, v: Option<&mlua::Value>) -> Option {
match v? {
mlua::Value::Integer(i) => u32::try_from(*i).ok(),
mlua::Value::Number(n) => {
@@ -1549,7 +1549,8 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
//
// Why these two matter more than a missing-global warning: DoiteAuras' `_SafeSpellUsable`
// returns `1, 0` — usable, not out of power — when `IsSpellUsable` is absent, so every
- // usability condition passed and an aura gated on one showed **always**.
+ // usability condition passed and an aura gated on one showed **always**. Answering with the
+ // wrong *shape* lands in the same place, which is what the return comment below is about.
lua.globals().set(
"IsSpellUsable",
lua.create_function(|lua, ident: Option| {
@@ -1565,9 +1566,23 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
}) else {
return Ok((None, None));
};
- // 1/nil, never a boolean: `_SafeSpellUsable` hands the pair straight back to callers
- // that test it numerically.
- Ok((found.0.then_some(1u32), Some(u32::from(found.1))))
+ // **1/0, and never nil for a spell the book knows.** Its callers switch on the
+ // number, and one refuses a nil outright: `SuperCleveRoidMacros/Core.lua:1035`
+ // has a branch labelled "IsSpellUsable returned 0 = wrong stance/form, not enough
+ // power type", reading the value straight out of `pcall(IsSpellUsable, …)`, and
+ // DoiteAuras' `_SafeSpellUsable` accepts an answer only `if ok and u ~= nil`
+ // (`DoiteConditions.lua:970`). Handed nil it walked its fallback chain to
+ // "treat as usable so icon doesn't die forever" (`:1022`), so **every** Usable-mode
+ // aura lit on cooldown alone, with stance, rage, range and reagents ignored. 0 is
+ // truthy in Lua, which would matter if a caller tested the value for truth; no caller
+ // in the corpus does so unguarded: `AlternativeClassicActionBars/Button.lua:1907`
+ // writes `elseif usable and usable ~= 0 then`, which is a line nobody writes against
+ // a function that answers 1/nil — it is the clearest statement of the convention in
+ // the corpus, and the rest compare `== 1`.
+ //
+ // The second return was already 0/1 rather than `IsUsableAction`'s 1/nil, so this
+ // makes the pair one convention instead of two.
+ Ok((Some(u32::from(found.0)), Some(u32::from(found.1))))
})?,
)?;
@@ -3234,10 +3249,11 @@ mod tests {
.unwrap());
}
- /// `IsSpellUsable` answers the feed's pair as 1/nil and 1/0, never booleans: DoiteAuras'
- /// `_SafeSpellUsable` hands both straight back to callers that test them numerically. A spell
- /// outside the player's own book answers nothing at all, which is upstream's stated contract
- /// and the signal the caller's fallback waits for.
+ /// `IsSpellUsable` answers the feed's pair as two numbers, 1/0 either side, never booleans
+ /// and never nil for a spell the book knows — `0` is the answer the DLL's own callers switch
+ /// on, and a nil is one of them (DoiteAuras) walking a fallback chain that ends in "usable".
+ /// A spell outside the player's own book answers nothing at all, which is upstream's stated
+ /// contract and the signal the caller's fallback waits for.
#[test]
fn spell_usable_answers_the_live_pair_and_nil_outside_the_book() {
let mut s = UiScript::new().unwrap();
@@ -3253,11 +3269,22 @@ mod tests {
.collect(),
);
- assert_eq!(s.eval::("return IsSpellUsable(6673)").unwrap(), 1);
+ let fine: String = s
+ .eval("local a, b = IsSpellUsable(6673) return tostring(a) .. \"|\" .. tostring(b)")
+ .unwrap();
+ assert_eq!(fine, "1|0", "usable, and not for want of power");
let oom: String = s
.eval("local a, b = IsSpellUsable(20252) return tostring(a) .. \"|\" .. tostring(b)")
.unwrap();
- assert_eq!(oom, "nil|1", "unusable, and the power gate is why");
+ assert_eq!(oom, "0|1", "unusable, and the power gate is why");
+ // The gate both callers put the answer through, quoted: DoiteAuras' `_SafeSpellUsable`
+ // keeps a verdict only `if ok and u ~= nil` (`DoiteConditions.lua:970`), and a nil sent
+ // it down a chain ending at "treat as usable so icon doesn't die forever".
+ assert!(
+ s.eval::("local ok, u = pcall(IsSpellUsable, 20252) return ok and u ~= nil")
+ .unwrap(),
+ "the callers' own accept test refuses our answer for an unusable spell"
+ );
// A name resolves through the book, highest rank first — rank 2 of Battle Shout.
assert!(s
.eval::("return IsSpellUsable(\"Battle Shout\") == nil")
diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md
index 1a4d5b2a..380c85bb 100644
--- a/docs/ADDON_COMPAT.md
+++ b/docs/ADDON_COMPAT.md
@@ -236,7 +236,7 @@ separately, so "cannot say" has to be distinguishable from "no".
| State (bare globals) | partial | `compat/classic_api/state.rs` + new per-tick posture plumbing: `SelfMovement` (`script/model.rs`), `UiScript::set_self_movement` (`script/self_movement.rs`), fed by `ui_unit::feed_self_movement` from the mover's movement flags (swim, fall), the descriptor (mount display id, `UNIT_FIELD_BYTES_2` form byte) and the area pass's own indoor down-ray. 1.12's Lua can reach none of these at all, so this is new engine data, not a wrapper. Done: `IsSwimming`, `IsMounted`, `IsIndoors`, `IsOutdoors`, `IsFalling`, `GetShapeshiftFormID`, and `IsInGroup`/`IsInRaid` (read the party/raid roster, the same authority `GetNumPartyMembers`/`GetNumRaidMembers` answer from — not duplicated into the posture snapshot). Not built, each for a stated reason in that file's header: `IsStealthed`/`GetSheathState`/`IsAssistingRitual` (no engine state tracks any of the three), `GetMirrorTimerInfo`/`GetMirrorTimerProgress` (benilla's mirror timers are event-only — `ui_mirror.rs` fires `MIRROR_TIMER_START`/`_PAUSE`/`_STOP` and keeps no queryable snapshot), `Dismount`/`CancelShapeshiftForm` (actions; vanilla cancels a form by re-casting its spell, and the engine knows the form *index*, not the spell behind it). Cost: `UPDATE_ACTIONABLE_CEILING` +3, cited in `game_plugins.rs`. **This same plumbing unblocks SuperWoW's own lowercase-initial `IsSwimming`/`IsMounted`/`isIndoors` siblings** (its "New functions" row above) — a later pass can wire those to `SelfMovement` directly instead of building anything |
| A **non-player** aura's `duration` / `expirationTime` | done | `crates/benilla-app/src/ui_aura.rs`'s `OtherAuraDurations`: a `(unit guid, spell id)` table stamped by `spell/net.rs`'s `on_spell_go`, joined into every other unit's aura list at the feed. **This was the hole behind "no debuff timers on the target frame".** pfUI-for-ClassicAPI reads its unit-frame timers straight off `C_UnitAuras.UnitAuraBySlot`'s `duration`/`expirationTime` (`api/unitframes.lua:1680,1788`) rather than through libdebuff — its own header says the plumbing "now runs on ClassicAPI's C_UnitAuras (which provides sourceUnit/sourceGUID and **non-player expirationTime**)" and libdebuff's `UnitDebuff`/`UnitOwnDebuff` survive only as adapters over it. So libdebuff could be tracking perfectly (it was: `libdebuff_all_auras` held rows) and the icons still draw no sweep, because the number they read came from us and we answered 0. There is no packet to answer from: `SMSG_UPDATE_AURA_DURATION` is self-only and `SMSG_SET_EXTRA_AURA_INFO`, which carries a caster's durations on others, is a 2.0 opcode. It is learned instead from the GO that applies the aura — which this client sees natively, with the apply instant being the packet's own arrival rather than a combat-log line to parse. Length comes from the same `ui_cast_events::aura_duration_ms` behind nampower's `arg8` and `GetSpellDuration`, so the three cannot disagree, and its `0` (permanent, no row, or a combo-point/level-scaled row — the `todo` row above) stamps nothing, leaving the icon timerless rather than counting to the wrong moment. The join is driven by the unit's own `UNIT_FIELD_AURA`, so a stamp for an aura the target resisted is never read, and a stamp that has run out reports nothing rather than a negative remainder. Not covered, by construction: an aura already on a unit before we streamed it, and one applied out of our range — no GO, no timer, which is what the mods this stands in for also do |
| UnitAuras (`C_UnitAuras`) | done | `compat/classic_api/unit_auras.rs`, modern named-table/slot shape over the same `AuraState` list the stock `UnitBuff`/`UnitDebuff`/`GetPlayerBuff` already read (including this session's SuperWoW spell-id extension). `GetAuraDataByIndex`, `GetBuffDataByIndex`, `GetDebuffDataByIndex`, `GetAuraDataBySlot`, `UnitAuraBySlot`, `GetAuraSlots`, `GetUnitAuras`, `GetUnitAuraBySpellID`, `GetPlayerAuraBySpellID`, `GetAuraDataBySpellName`, `GetAuraDispelTypeColor`, and the positional `UnitAura`/`UnitBuff`/`UnitDebuff` siblings. `RegisterComboDuration`/`RegisterAuraDurationModifierByTrigger` accept and discard: they are addon-side registration hooks with no 1.12 data behind them and nothing here would ever read the registration |
-| Spell (`C_Spell`) | partial | `compat/classic_api/spell.rs`, spell-id-first: `DoesSpellExist`, `GetSpellName`, `GetSpellTexture`, `GetSpellSubtext`, `GetSpellInfo` (named table), `IsSpellPassive`, `IsSpellKnown`, `IsCurrentSpell`, `GetSpellCooldown`, `IsSpellUsable`, `GetSchoolString` (+ the seven `SCHOOL_MASK_*` constants), `CanDualWield`, `CastAtCursor`, `CastAtUnit`, `CancelSpellByID`. **The five display getters answer for any shipped spell**, not just the player's own ~200: `script/spellbook.rs`'s `spell_display` reads the book first (it carries the live icon substitutions) and falls back to the host's `Spell.dbc` probe for every other id — the fix for pfUI's `api/unitframes.lua:42`, which keys a predict table on `strlower(GetSpellTexture(774))` at file scope on every class. The probe carries only name/rank/icon, so the predicates below it (`IsSpellPassive`, `IsSpellKnown`, `IsCurrentSpell`, `GetSpellCooldown`, `IsSpellUsable`) still read the book alone, which is the right source for all five. The rest of the namespace's ~30 functions (`GetSpellDescription`, `GetSpellPowerCost`, `GetSpellReagents`, `GetSpellEffectInfo`, the range/harm/helpful predicates, `UnitCastingInfo`/`UnitChannelInfo`, `GetSpellLevelInfo`, …) are unstarted: each needs a `SpellDisplay` column the probe does not carry, and widening `SpellLookup` field by field is the route, with a reason each time |
+| Spell (`C_Spell`) | partial | `compat/classic_api/spell.rs`, spell-id-first: `DoesSpellExist`, `GetSpellName`, `GetSpellTexture`, `GetSpellSubtext`, `GetSpellInfo` (named table), `IsSpellPassive`, `IsSpellKnown`, `IsCurrentSpell`, `GetSpellCooldown`, `IsSpellUsable`, `GetSchoolString` (+ the seven `SCHOOL_MASK_*` constants), `CanDualWield`, `CastAtCursor`, `CastAtUnit`, `CancelSpellByID`. **The five display getters answer for any shipped spell**, not just the player's own ~200: `script/spellbook.rs`'s `spell_display` reads the book first (it carries the live icon substitutions) and falls back to the host's `Spell.dbc` probe for every other id — the fix for pfUI's `api/unitframes.lua:42`, which keys a predict table on `strlower(GetSpellTexture(774))` at file scope on every class. The probe carries only name/rank/icon, so the predicates below it (`IsSpellPassive`, `IsSpellKnown`, `IsCurrentSpell`, `GetSpellCooldown`) still read the book alone, which is the right source for those four. `IsSpellUsable(id|name)` is the exception: it answers the same usable-walk feed the nampower row below describes, as two **booleans** (`isUsable, insufficientPower`, upstream's own shape, and what its caller hands to `SetShown`), because upstream's list of what makes a spell unusable is "hasn't learned it, lacks mana or reagents, reactive conditions haven't been met" and not cooldown. It takes a name as well as an id: pfUI's reactive indicator passes one (`modules/nampower.lua:130`). The rest of the namespace's ~30 functions (`GetSpellDescription`, `GetSpellPowerCost`, `GetSpellReagents`, `GetSpellEffectInfo`, the range/harm/helpful predicates, `UnitCastingInfo`/`UnitChannelInfo`, `GetSpellLevelInfo`, …) are unstarted: each needs a `SpellDisplay` column the probe does not carry, and widening `SpellLookup` field by field is the route, with a reason each time |
| Spell — the per-unit cast feed (`C_Spell.UnitCastingInfo`, `UnitChannelInfo`, `CastingInfo`, `ChannelInfo`) | partial — **the feed is built; the `UNIT_SPELLCAST_*` events that drive pfUI's bars are not** | Split out of the `C_Spell` row above rather than folded into it, because this half is a live feed rather than a getter over data already in hand, and because what it does *not* yet reach is worth a row of its own. This closes four of the "ClassicAPI tail" row's 46 measured members — the three it filed under "needs a live feed" for cast state, plus `CastingInfo`, which upstream lists in the same breath (`docs/classic-api-functions.md`, the Spell section) and which is `UnitCastingInfo("player")` exactly. **Built**: `compat/classic_api/spell.rs`'s `cast_info` over `Model::unit_cast`, fed from `benilla-app`'s `ui_cast_events.rs` ([`CastWindows`]), which folds the **same queue of wire edges** that already raises SuperWoW's `UNIT_CASTEVENT` — `SMSG_SPELL_START`, `SMSG_SPELL_GO`, `SMSG_SPELL_FAILED_OTHER`, `MSG_CHANNEL_START`, plus three state-only edges added here (`SMSG_SPELL_DELAYED` pushback, `MSG_CHANNEL_UPDATE`, and `inflight.rs`'s local Esc/move cancel, none of which is one of the wiki's six `UNIT_CASTEVENT` words, so each folds state and fires nothing). One queue, not two: an addon woken by `UNIT_CASTEVENT` polls `C_Spell` for the timing the event does not carry (pfUI, `modules/nameplates.lua:648-656`), and each row's state is pushed to the VM *before* its own event fires so the two cannot contradict each other — `the_event_and_the_window_agree_inside_the_handler_the_event_woke` is that property, and it caught a real one-tick-stale bug on the way in. **Keyed by caster GUID, deliberately not a `UnitState` field**: tokens are a naming layer that moves under units, and a cast carried on `"target"`'s snapshot would hand the next target the old one's half-finished bar. A read folds token -> GUID through `Model::unit`, so every token spelling this client answers (stock, a raw SuperWoW GUID string, `"mark"`, `"owner"`, ClassicAPI's `"focus"`, `nameplate`) reads the right window for free. **Units**: `startMs`/`endMs` are **milliseconds** on `GetTime()`'s clock, stamped at the drain — the same place and frame `ui_cast.rs` stamps the stock bar's `SPELLCAST_START`, so pfUI's bar and `CastingBarFrame` start on the same millisecond. The two verbs are disjoint (a channel answers only `UnitChannelInfo`), and the shapes differ by one field: `castID` exists for a cast and not for a channel, so `spellID` is the 9th return and the 8th respectively — both of pfUI's destructurings (`modules/nameplates.lua:83`/`:86`) are transcribed verbatim as the test, in `script/tests/cast_info.rs`, because a positional shape is only correct relative to the code that reads it. **Not backed, left nil rather than guessed** (each with its reason in `cast_info`'s doc): `isTradeskill` — 1.12 has no tradeskill column in `Spell.dbc` and no wire bit, so pfUI's queued-craft bar merge simply does not engage; `castID` — 1.12 carries no per-cast identifier anywhere; `notInterruptible` — no such flag exists in the client, in `Spell.dbc` or on the wire, and nil reads as false, which is what every 1.12 cast is; return slot 10 of the cast form — upstream puts something there (pfUI skips exactly one slot to reach `delayMs` at 11) and nothing in this tree says what. `text` (slot 2) answers as `name`; the one per-cast string the reference does spell differently is the stock channel bar's `CHANNELING` word, which is an argument of `SPELLCAST_CHANNEL_START` for the local player, not a per-unit field, and no consumer reads slot 2. `delayMs` (slot 11) **is** backed, from the accumulated `SMSG_SPELL_DELAYED` total. **Another unit's channel is not reachable** and is reported as nothing rather than faked: `MSG_CHANNEL_START`/`UPDATE` carry no caster and vmangos sends them to the caster alone, so only the local player's channel has a window. `UNIT_FIELD_CHANNEL_SPELL` (field 144, public) does name *which* spell another unit is channelling, but carries no start and no length, and a bar with an invented timer is worse than no bar — that field plus a `SpellDuration.dbc` column, with the 0 -> id edge as the start and an explicit nothing for a channel already running when the unit streamed in, is the measured route if it is ever wanted. **What this does not finish**: pfUI's cast bars are driven by ClassicAPI's `UNIT_SPELLCAST_*` token events, which benilla fires none of (the row below costs the per-`nameplate` half; the stock `player`/`target`/`focus` half is equally unbuilt). Without them `modules/castbar.lua` stamps a bar only from its `PLAYER_TARGET_CHANGED`/`PLAYER_FOCUS_CHANGED` re-poll (`:340`) — so **a mob already mid-cast when you target it does now show a correct, correctly-timed bar, and one that starts casting while already targeted does not**; the player's own `pfPlayerCastbar` never stamps at all, since `arg1 == "player"` events are its only way in. Nameplates are the same shape: `modules/nameplates.lua:602` seeds `castState[guid]` from `PollCastInfo` at `NAME_PLATE_UNIT_ADDED`, so a plate that spawns on a casting mob draws its bar, while `:655`'s per-cast seed waits on the event. The feed was the blocking half (the row below records that an event with nothing to read behind it "would fire into a handler that learns nothing"); the events are now the whole remainder, and `C_Spell` is no longer what blocks them |
| Spell — the `UNIT_SPELLCAST_*` token events | done | **The other half of the row above, and the half pfUI's cast bars are actually driven by.** The feed answered *what* a unit is casting; nothing told an addon *when*, so `modules/castbar.lua` could only stamp a bar from its `PLAYER_TARGET_CHANGED`/`PLAYER_FOCUS_CHANGED` re-poll and `pfPlayerCastbar` never stamped at all. Built in `benilla-ui`'s new `script/cast_events.rs` (`UiScript::fire_unit_spellcast`, the fan-out and the arguments) plus `benilla-app`'s `ui_cast_events.rs` (`CastEdge::token_events`, which edge raises which event), fired by the existing `drain_cast_edges` off the **same queue of wire edges** that already raises `UNIT_CASTEVENT` and folds the windows — so an addon woken by an event and polling `C_Spell` in that handler cannot be told two different things. **Built: nine events.** `_START` (`SMSG_SPELL_START`), `_CHANNEL_START` (`MSG_CHANNEL_START`), `_SUCCEEDED` + `_STOP` (`SMSG_SPELL_GO`), `_INTERRUPTED` + `_STOP` (`SMSG_SPELL_FAILED_OTHER`, and the local Esc/move abort), `_FAILED` + `_STOP` (a failing `SMSG_CAST_RESULT`), `_DELAYED` (`SMSG_SPELL_DELAYED`), `_CHANNEL_UPDATE` and `_CHANNEL_STOP` (`MSG_CHANNEL_UPDATE`, split on its remaining time at 0). **Each is the stock bar's own word for the same packet** — the mapping is `ui_cast.rs`'s `CastBarEdge` one for one, which is the strongest available statement that `pfPlayerCastbar` and `CastingBarFrame` cannot tell two stories about one cast; `every_token_event_is_the_stock_bars_own_word_for_the_same_packet` is that table. The millisecond claim is the two drains' shared frame clock: both are in `UnitFeed`, both stamp off `script.now()`, and `the_players_bar_walks_start_pushback_and_stop_in_step_with_the_stock_bar` interleaves `SPELLCAST_START@100.000` with pfUI's own `startTime` of `100000` to show it. **`arg1..arg5` are `(unit, castGUID, spellID, name, rank)`**, taken from the consumer's own comment (`modules/castbar.lua:354`) and its reads (`arg5` → the bar's rank label at `:357`/`:102-107`, `arg3` → the craft counter at `:370`; the nameplate module reads `arg1` alone behind `strfind(arg1, "^nameplate")`). `arg2` is **nil** — 1.12 has no per-cast identifier anywhere, the same reason `UnitCastingInfo`'s `castID` is nil, and the two must agree. Name and rank come from the one `spell_display` body `C_Spell.GetSpellName`/`GetSpellSubtext` read, because pfUI *prefers* `arg5` over its own `GetSpellSubtext` fallback and two sources would print two ranks. All five slots are pushed on every fire, nil included: the handler is called positionally too, so a short push is a shorter event to anything reading `...`. **The per-token/per-GUID mismatch, which is the whole design.** A cast is per-GUID; these events are per-token; one mob can be `target` *and* `nameplate3` *and* `focus` at once and each drives a different frame, so a new `Model::tokens_for_guid` answers **every** live token and the fan-out fires once per token — `token_for_guid` (`UnitTokenFromGUID`'s singular) returns whichever the map walk reaches first, and firing under that one leaves whichever bar lost the coin flip permanently blank (`one_cast_fires_once_per_live_token_that_names_its_caster` fails with exactly one token when collapsed). The set is the **pushed token keys** only — `player`, `target`, `targettarget`, the focus pair, `mouseover`, `npc`, party/raid/pet, `nameplate` and its target — deliberately not the derived spellings `Model::unit` also resolves (a raw 16-hex GUID, `mark`, `owner`): those are how a *caller* spells a unit it already holds, nothing registers a handler keyed on one, and fanning every edge across `mark1..8` plus an `owner` form per base would multiply the dispatch for no reader. Tokens resolve at **fire** time, not start time, which is what makes token loss safe: retarget off a casting mob and its `STOP` has no `target` to fire under, so the new target's bar survives the old cast ending. **Nothing fires on token *acquisition*** — pfUI already covers that itself (`castbar.lua:343`'s re-poll, `nameplates.lua:602`'s `NAME_PLATE_UNIT_ADDED` seed), so a synthetic start there would both duplicate it and be an event with no wire edge behind it. **Two orderings are read off the consumer, not guessed.** (1) A failing word always comes **before** its `STOP`: `ClearBar(cb, failed)` paints the cancelled-cast red flash only if `wasActive = cb.endTime ~= nil` (`:25`), and a plain `STOP` first nils `endTime` so the flash never paints — reversing the pair makes `failcolor` nil, verified. Both must fire, because the nameplate module registers `_STOP` and *not* the failing pair (`nameplates.lua:506-507`) and that is its only way to clear `castState[guid]`; without it an interrupted mob's plate bar runs to the cast's original end. (2) Events are **event-major, not token-major**, so when one unit answers two of a consumer's tokens (self-targeted: `player` and `target`, and pfUI's target bar handles both through its `UnitIsUnit(unit, "player")` leg) the failing word reaches that bar before any `STOP`. **Gating: `folded` — the window fold's own return — for every edge**, so events and windows are one state machine. A `START` fires exactly when a window opened, which means `Start { window: false }` (`opens_cast_bar`'s verdict, the *same* body `spell_start` uses for `SPELLCAST_START`) raises no token start, so Auto Shot's mid-Aimed-Shot `SMSG_SPELL_START` neither opens a bar nor reaps one; a terminal fires exactly when a window closed, which de-duplicates the `SMSG_CAST_RESULT`/`SMSG_SPELL_FAILED_OTHER` pair and the abort/FAILED_OTHER pair for free; and a pre-start rejection naming some *other* spell folds nothing, so it cannot reach in and wipe the bar of the cast actually running (pfUI's `ClearBar` has no way to tell which spell an event is about). **Two pre-existing window bugs this surfaced and closed.** (a) A failing `SMSG_CAST_RESULT` pushed no edge at all, so a cast the server killed with nothing but that packet — a mid-cast deselect, vmangos `HandleSetSelectionOpcode` → `Spell::cancel` → `SendCastResult(INTERRUPTED)` — kept reporting as live to its original end: a bar that starts and never ends. Now `CastEdge::ResultFail`, queued inside `cast_result` beside the `CastBarEdge::Failed` it mirrors. It raises **no** `UNIT_CASTEVENT`: that row sources FAIL from `SMSG_SPELL_FAILED_OTHER` alone, both packets reach us for one interrupted cast of our own, and doubling a word an addon counts to answer an unsettled question about the real hook would be worse than leaving it. (b) `prune` ran unconditionally before the rows, and a completed cast's `SMSG_SPELL_GO` lands within a frame of its window's own end *either side of it* (the end is `our START receipt + cast time`, the GO is sent at `server START + cast time`), so the GO found nothing to close and `_STOP` fired on a coin flip. `CastEventFeed::reprieve` now spares a window whose resolution is already in this tick's queue, keyed on the spell id like every other removal here, so the expiry sweep still means exactly what it says — a resolution that never came. **Not built, each with its reason.** `UNIT_SPELLCAST_SENT` — it fires at the client's own send, not off a packet, and no addon in the corpus registers it: a new producer in `cast_send.rs` for no reader. No `_STOP` for a window the expiry sweep *does* take (caster despawned, packet for a unit that left streaming range): both consumers end a bar at its own nominal end with no event (`castbar.lua:286-289`, `nameplates.lua:106`), and such a caster is typically named by no token to fire under anyway. No `_CHANNEL_STOP` when a cast `START` replaces a live channel — pfUI's `START` arm re-stamps and its nameplate arm overwrites, so the transition needs no announcement. `_SUCCEEDED` is fired for conformance, not for an effect that can be shown here: its only consumer is pfUI's craft counter, reachable only through `isTradeskill`, which 1.12 has no column for and the feed answers nil for by design (`merged` is asserted nil in the timing test). One **measured divergence left open**: the stock channel bar applies one gate the window does not (`0x6e7550`, `AttributesEx3 & 0x2000`), and it is unreachable — `benilla-formats`' `spells/display.rs:363-367` records that exactly two spells carry the bit, Blood Siphon 24322/24323, neither player-castable, and `MSG_CHANNEL_START` reaches us for the local player's own channel alone. The other arm of that gate (no `Spell.dbc` row) is invisible rather than divergent: a window whose id nothing can name makes `cast_info` answer nil, so no bar is drawn either way. **This also closes the cast half of the per-`nameplate` row below**, which recorded the cast pair as blocked on `C_Spell.UnitCastingInfo`; that is built, the plate tokens do now receive `_START`/`_CHANNEL_START`/`_STOP`/`_CHANNEL_STOP`, and `a_nameplates_token_gets_the_cast_pair_its_module_registers_and_the_stop_that_clears_it` runs that module's transcribed arms. `UNIT_AURA`/`UNIT_FLAGS` per plate stay as that row leaves them. **Evidence**: `benilla-ui`'s `script/tests/cast_info.rs` for the fan-out and the five arguments, and `ui_cast_events.rs`'s own tests for the sequences — the latter transcribe pfUI's `ClearBar`/`StampBar`/`RefreshBar`/`OnUpdate` and its whole `OnEvent` body (`castbar.lua:24-39`, `:89-144`, `:148-175`, `:250-306`, `:340-420`) verbatim and run them against the real fold with both drains in one app, because an event's contract is only correct relative to the code that handles it. **Ambiguity cost: zero, measured.** `Update (actionable)` is 5581 against a ceiling of 5581, unmoved, and `PostUpdate` 466 — no new system (the work is inside the existing VM-holding `drain_cast_edges`, which `WOW_AMBIGUITY_DUMP=1` on the ratchet test, grepped for `drain_cast_edges`, puts at 0 pairs, being wholly in the VM-only explained bucket), no new `SystemParam` on it (the token set and `spell_display` are both reached through `UiScript`, on the far side of the boundary), and the one new app-side param — `Reply`'s `ResMut` — is on `on_cast_result`, a `net_handler` one-shot that is not in `Update` at all (the same dump greps to 0 for it too). No new `benilla-app` file, so `ui_script_wall`'s game count stays at its pre-existing 43. No new bare Lua globals, so `reference_surface.rs` is untouched; the nine event names are modern and absent from `reference/1.12-events.tsv`, so `event_shape_gate` skips them as it does `UNIT_CASTEVENT` and the `NAME_PLATE_*` three. Harness unchanged at 13/13 load clean, 13/13 session start, 13/13 UI probe, 10/13 survived use — it drives no world, so no cast packet ever arrives and it cannot see this feature at all. |
| SpellBook (`C_SpellBook`) | partial | `compat/classic_api/spellbook.rs`: `FindSpellBookSlotByID`, `GetSpellBookItemInfo` (named table), `GetNumSpellBookSkillLines`, `GetSpellBookSkillLineInfo`, `GetSpellBookItemSkillLineIndex`. The remaining ~14 (`GetSkillLineIndexByID`, `GetSpellBookItemCastCount`, the loss-of-control pair, `IsClassTalentSpellBookItem`, `ContainsAnyDisenchantSpell`, `GetSpellLevelLearned`, `GetCurrentLevelSpells`, `GetPlayerSpellsByAura`, the skill-line name/rank getters, the auto-attack predicates) are unstarted; some have no 1.12 equivalent concept at all (modern "class talent" vs 1.12's talent tabs, loss-of-control as distinct from 1.12's CC) |
@@ -572,7 +572,7 @@ with the load checked after each.
| `C_Spell.GetSpellMechanicByID` / `GetSpellEffectMechanics` / `GetSpellPowerCost` | **built** | One probe for all three (`SpellDbcExtras`): same `Spell.dbc` row, same install site, and a caller asking for either has already paid the lookup. Its own probe rather than columns on `SpellLookup`, which has eleven construction sites with no answer for them. **`GetSpellMechanicByID` is three-way** and the consumer spells it out: nothing for an id no spell owns, `0` for a real spell with no mechanic, the id otherwise — it replaces a hand-kept spellID → mechanic table, so collapsing "unknown" into "none" would reclassify crowd control as plain damage. The enUS name is the second return, from `SpellMechanic.dbc` (which this tree already parsed). **`GetSpellEffectMechanics` is the per-effect sibling**, `EffectMechanic[3]` as `{m1, m2, m3}`, and it is not the same question: vanilla puts some mechanics on an effect rather than on the spell, so Rake's bleed answers `{0, 15, 0}` where `GetSpellMechanicByID` answers 0 — a macro conditional asking whether its target is bleeding gets the wrong answer from the spell-level column alone. Same three-way shape (nothing for an unknown id, three zeros for a spell with no mechanic anywhere). `SpellDisplay` already parsed the column for the cast-immunity scan, so this cost a field on the probe payload and nothing else. Its absence was one raise a session from `SuperCleveRoidMacros/ClassicAPI.lua:267`, which calls it unguarded. **`GetSpellPowerCost` answers a LIST** (`costs[1].cost`), one row, since 1.12 has a single cost per spell; it is the flat `manaCost` column **without** the reference's per-level and percentage terms (`GetPowerCost` `0x6e31b0`), which need the caster's level and max power — stated because it is the base cost a macro reader wants, not what a scaling spell would actually spend |
| nampower `GetUnitField` / `GetSpellRecField`, version 4.1.3 | **built** | `GetUnitField(unit, field)` is nampower v3+'s window onto the raw `UNIT_FIELD_AURA` block, and the reason it exists is that its slots are **stable**: every 1.12 aura getter walks the *compacted* list, so each index shifts the moment an earlier aura drops and nothing can be keyed on it across frames. Two fields: `"aura"` → slot → spell id, `"auraApplications"` → slot → **stacks minus one** (the wire's own encoding; pfUI reads it back as `(apps[slot] or 0) + 1`, so returning `stacks` would show every debuff one stack high). **Slots are reported 1-based** — debuffs 33..48, which both consumers loop literally — over benilla's 0-based wire index. Needed new state: `AuraState::slot`, since the VM's aura list was compacted and had dropped the raw index; filled at both app-side builders. Accepts a token or a raw GUID (pfUI passes `GetDebuffSlotMap(guid)`, DoiteAuras passes `"player"`), folding a guid back through `token_for_guid`. Reads the same filtered list every other getter does — hidden-from-aura-bar and tracking auras are already gone, which is what a debuff tracker wants, and the slot keying is unaffected because a dropped aura reads nil at its slot rather than shifting its neighbours. `GetSpellRecField(spellID, field)` is **partial on purpose**: `name` and `rank` are answered, `stackAmount`/`manaCost`/`spellIconID` are **nil rather than a plausible zero** — this client reaches no other column of that row, and a fabricated `manaCost` of 0 reads as "free", which is exactly the test DoiteAuras' Clearcasting rule turns on. `spellIconID` is not derivable at all: the icon is carried as an MPQ path, not as the numeric id that names it. With all three in place `GetNampowerVersion` moves 4.0.0 → **4.1.3**, which is the gate DoiteAuras gates on — bumped only *after* the machinery existed, per that constant's own standing rule not to claim what is absent. Measured: DoiteAuras goes from refusing to open to **0 load / 0 session errors**; pfUI's `libs/libdebuff.lua` ("GetUnitField Edition") stops falling back to manual slot shifting |
| nampower's six spellbook/duration verbs | **built** | `GetSpellNameAndRankForId(id)`, `GetSpellIdForName(name)`, `GetSpellSlotTypeIdForName(name)` → `slot, bookType`, `IsPassiveSpell(index, bookType)`, `GetSpellDuration(id[, ignoreModifiers])` → ms, and `GetPlayerAuraDuration(auraSlot)` → `spellID, durationMs, expirationTimeMs` (`script/compat/nampower.rs`). Signatures are **not inferred**: `SuperCleveRoidMacros/NampowerAPI.lua` is a shipped, documented wrapper over this whole surface and states each one's arguments, returns and units, which is what these were built against. Why they matter beyond a missing-global warning: DoiteAuras takes each as a *fast path* and otherwise falls back to a 200-slot `GetSpellName` scan **per lookup, per tracked aura, per condition evaluation**. None is expressible in 1.12: that client walks a book by index and reads a name, but has no id for what it finds and no way back from a name to either, and it exposes a running aura's remaining time (`GetPlayerBuffTimeLeft`) but no way to ask a spell how long its aura lasts *before* casting it. The two name→X verbs answer the **highest rank known**, as `CastSpellByName` does. **The two slot conventions deliberately disagree**: `GetUnitField` is 1-based (debuffs 33..48) and `GetPlayerAuraDuration` is raw 0-based (0..31 buffs, 32..47 debuffs) — each is what its own consumers loop over, and matching them to each other would break both. `GetSpellDuration` reads `SpellDuration.dbc` through the same `ui_cast_events::aura_duration_ms` that answers nampower's `SPELL_START_*` `arg7`, so the event and the query cannot disagree; it keeps upstream's three-way answer (nil for no such spell, 0 for a spell whose length this client will not state, ms otherwise) because a consumer treats 0 as absent and falls back, while a number it trusts and draws as a timer. `ignoreModifiers` is accepted and ignored: it asks for the base duration, and the base is all this client has (`spell/mods.rs` stores the server's `SPELLMOD_DURATION` cells but has no reader) |
-| nampower `GetSpellIconTexture` / `GetItemSpell` / `IsSpellInRange` / `IsSpellUsable` / `GetAmmo` | **built** — this row replaces the `todo` it was written as | The last five of DoiteAuras' eleven probes, so all eleven now answer. `IsSpellUsable(id|name)` -> `usable, oom` and `IsSpellInRange(id|name[, unit])` read a live per-spell map `ui_action::state` pushes over the player's **whole book**, computed by the reference's own usable walk (`0x6e3d60`) and range verdict (`0x4e56f0`) — the same computation the action bar greys its buttons with, so a spell on a bar and the same spell asked for here cannot disagree. That feed is the reason these are not a forward of `IsUsableAction`, which is keyed by *action-bar slot*: a spell the player knows but has not placed on a bar has no slot to ask through. Swept at ~10 Hz (a cooldown-generation change jumps the queue) because a book is ~150 spells and the walk reads equipment and reagents per spell. Range is measured against the **current target**, as the bar's own light is, so naming any other unit answers nil rather than quoting the target's distance under another unit's name. `IsSpellUsable`'s absence was the one with teeth: DoiteAuras' `_SafeSpellUsable` returns `1, 0` without it, so every usability condition passed and an aura gated on one showed **always**. `GetSpellIconTexture(spellIconID)` needed the pair's other half built first — `SpellDisplay` now keeps `icon_id` beside the path it resolves to, and the catalog keeps `SpellIcon.dbc` itself, so `GetSpellRecField(id, "spellIconID")` answers a real row id and this resolves it back (verified end to end: spell 6673 -> icon 456 -> `Interface\Icons\Ability_Warrior_BattleShout`). `GetItemSpell(itemID)` answers the **Use:** spell only (wire triggers 0 and 5), never an equip or proc one, or every stat trinket would read as an activatable. `GetAmmo()` is the doll's slot 0, whose count the app already sums across quivers for the low-ammo alert. Signatures throughout are **not inferred**: `SuperCleveRoidMacros/NampowerAPI.lua` is a shipped wrapper documenting each one's arguments, returns and units |
+| nampower `GetSpellIconTexture` / `GetItemSpell` / `IsSpellInRange` / `IsSpellUsable` / `GetAmmo` | **built** — this row replaces the `todo` it was written as | The last five of DoiteAuras' eleven probes, so all eleven now answer. `IsSpellUsable(id|name)` -> `usable, oom` (**two numbers, 1/0 either side** — `0` is what the DLL's own callers switch on — `AlternativeClassicActionBars/Button.lua:1907` writes `elseif usable and usable ~= 0 then`, a line nobody writes against a function answering 1/nil, and `SuperCleveRoidMacros/Core.lua:1035` labels its own branch "IsSpellUsable returned 0 = wrong stance/form"; a nil first return is DoiteAuras' `_SafeSpellUsable` discarding the verdict and walking a fallback chain that ends at "treat as usable", which lit every Usable-mode aura on cooldown alone) and `IsSpellInRange(id|name[, unit])` read a live per-spell map `ui_action::state` pushes over the player's **whole book**, computed by the reference's own usable walk (`0x6e3d60`) and range verdict (`0x4e56f0`) — the same computation the action bar greys its buttons with, so a spell on a bar and the same spell asked for here cannot disagree. That feed is the reason these are not a forward of `IsUsableAction`, which is keyed by *action-bar slot*: a spell the player knows but has not placed on a bar has no slot to ask through. Swept at ~10 Hz (a cooldown-generation change jumps the queue) because a book is ~150 spells and the walk reads equipment and reagents per spell. Range is measured against the **current target**, as the bar's own light is, so naming any other unit answers nil rather than quoting the target's distance under another unit's name. `IsSpellUsable`'s absence was the one with teeth: DoiteAuras' `_SafeSpellUsable` returns `1, 0` without it, so every usability condition passed and an aura gated on one showed **always**. `GetSpellIconTexture(spellIconID)` needed the pair's other half built first — `SpellDisplay` now keeps `icon_id` beside the path it resolves to, and the catalog keeps `SpellIcon.dbc` itself, so `GetSpellRecField(id, "spellIconID")` answers a real row id and this resolves it back (verified end to end: spell 6673 -> icon 456 -> `Interface\Icons\Ability_Warrior_BattleShout`). `GetItemSpell(itemID)` answers the **Use:** spell only (wire triggers 0 and 5), never an equip or proc one, or every stat trinket would read as an activatable. `GetAmmo()` is the doll's slot 0, whose count the app already sums across quivers for the low-ammo alert. Signatures throughout are **not inferred**: `SuperCleveRoidMacros/NampowerAPI.lua` is a shipped wrapper documenting each one's arguments, returns and units |
| `SetPortraitToTexture(region, path)` — the region form, and a nil path | **built** | It took only a texture *name* string, because both stock callers pass one (`ContainerFrame.lua:419`, `MailFrame.lua:174`) and this file's own comment recorded the object form as "untraced". OctoWoW's shop settled it: `Turtle_ShopUI.lua:893` passes the region and works on the real client. Refusing it raised **inside the shop's entry loop**, which aborted after the first card — and because the page count is written *before* that loop, the window read "Page 1/2" with one item and seven empty cells, which is the whole of the "can't see half the items" report. A nil path now clears rather than raising on argument #2: the same caller takes its texture from `GetItemInfo`, which answers nothing for an item the client has not cached |
| `OnHyperlinkEnter` / `OnHyperlinkLeave` | **built** | Fired by `pointer::update_hyperlink_hover` off the same rasterized link spans the click path reads, so a hover and a click can never disagree about where a link is. Not 1.12's — that client routes a link only on release (`OnHyperlinkClick`) — but TurtleWoW's adds them, and `SetScript` used to *raise* on the name under the standing rule that an accepted handler nothing fires is worse than a refused one. `ChatSuey-ItemLinkHover` is two handlers and nothing else, so the raise took the addon with it. The span is keyed by its markup, not its rect: a chat frame scrolls its lines under a still cursor, and re-entering the same link at a new y is not a new hover |
| A `