From e646b5a678d95734bb21d96ca667980d322dd6b2 Mon Sep 17 00:00:00 2001 From: grom Date: Sun, 1 Nov 2026 13:11:00 -0700 Subject: [PATCH] =?UTF-8?q?compat:=20the=20usable=20pair=20tells=20the=20t?= =?UTF-8?q?ruth=20=E2=80=94=201/0=20from=20nampower's=20verb,=20the=20walk?= =?UTF-8?q?=20behind=20C=5FSpell's?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in one pair of verbs, both found by reading what grom's addons do with what we answer rather than from a report. A DLL whose source is not to hand still has its contract written down: in the addons written against it. **`IsSpellUsable(id|name)` answers two numbers.** Ours gave 1/nil for the first — transcribed from `IsUsableAction`'s own convention — while already giving 0/1 for the second. Four readings in the corpus say otherwise, and `AlternativeClassicActionBars/Button.lua:1907` is the clearest: `elseif usable and usable ~= 0 then` is not a line anyone writes against a function that answers 1/nil. `SuperCleveRoidMacros/Core.lua:1035` labels its own branch "IsSpellUsable returned 0 = wrong stance/form, not enough power type", reading the value straight out of `pcall(IsSpellUsable, …)`. And the one with teeth: DoiteAuras' `_SafeSpellUsable` keeps a verdict only `if ok and u ~= nil` (`DoiteConditions.lua:970`), so a nil sent it down a fallback chain ending at `:1022`'s "treat as usable so icon doesn't die forever" — **every** Usable-mode aura lit on cooldown alone, stance, rage, range and reagents ignored. Answering the wrong shape and not answering at all land in the same place. 0 is truthy in Lua, so that was checked before changing it: no caller tests the value for truth unguarded, and the rest compare `== 1`. The DLL's string pool publishes the signature and the name-lookup error but not the return shape, and the five mirrors of the fork's `SCRIPTS.md` I could reach are each behind a Gitea login or 404 — the corpus was the better source. A name it cannot resolve raises there, which is why every caller wraps the call in `pcall`; ours answers `nil, nil`, and all four callers treat those identically. **`C_Spell.IsSpellUsable` was computing the wrong thing and raising on its caller's argument.** It was approximated as "known and not on cooldown" — the one reason upstream does *not* count (its own list is "hasn't learned it, lacks mana or reagents, reactive conditions haven't been met") and none of the ones it does. It now reads the same per-spell map the global does, the reference's usable walk (`0x6e3d60`), which agrees about cooldowns: "a running cooldown never greys here" (`spell/usable.rs` leg 11), leaving `GetSpellCooldown` the question about cooldowns. That makes it the honest answer for a reactive proc, which is exactly what its only caller asks: 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 inside the proc window and nowhere else, while "off cooldown" showed it nearly always. pfUI's reactive indicator (`modules/nampower.lua:130`) passes a **name** against a `u32` signature, which mlua refuses for a non-numeric string — a raise once per frame while the indicator was switched on. `spell_ident` is shared now, so a name resolves through the book as the global's does. Both returns there stay booleans, unlike the global's: the caller hands the first straight to `SetShown`, where a numeric 0 would be truthy and show every icon forever. Two conventions, each its own caller's, written down where they live. Tests: the global's pair is asserted as `1|0` and `0|1`, with the callers' own accept test (`pcall` + `u ~= nil`) quoted as an assertion; `C_Spell`'s is asserted over Overpower outside and inside the proc window with nothing about cooldowns changing, by id and by name, plus the not-in-book case answering false rather than raising. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- .../src/script/compat/classic_api/spell.rs | 95 ++++++++++++++++--- .../benilla-ui/src/script/compat/nampower.rs | 49 +++++++--- docs/ADDON_COMPAT.md | 4 +- 3 files changed, 123 insertions(+), 25 deletions(-) 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 `