diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs
index 5c6d1082..31649ad4 100644
--- a/crates/benilla-app/src/ui_script/lifecycle.rs
+++ b/crates/benilla-app/src/ui_script/lifecycle.rs
@@ -183,6 +183,14 @@ pub(crate) fn install_spell_probe(script: &mut UiScript, catalog: &benilla_forma
// a catalog hit that is hidden there must read hidden here, or `IsAuraHidden`
// would disagree with the aura list an addon reads beside it.
hidden_aura: d.hidden_from_aura_bar() || d.tracking_aura(),
+ // The three melee-timing answers, each from the formats crate's own named
+ // predicate or named bit, so the VM never sees an attribute word to test itself.
+ on_next_swing: d.on_next_swing(),
+ auto_repeat: d.auto_repeat(),
+ // "Casting this resets the swing timer": the DO_NOT_RESET_COMBAT_TIMERS bit
+ // clear, and not an on-next-swing ability — one of those rides the swing rather
+ // than restarting it, so it does not reset the timer either.
+ resets_melee_swing: !d.do_not_reset_combat_timers() && !d.on_next_swing(),
})
}));
}
diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs
index d37e11ae..4b597c7d 100644
--- a/crates/benilla-formats/src/spells/display.rs
+++ b/crates/benilla-formats/src/spells/display.rs
@@ -395,6 +395,14 @@ impl SpellDisplay {
&& self.attributes_ex2 & ATTR_EX2_DO_NOT_RESET_COMBAT_TIMERS == 0
}
+ /// `AttributesEx2 & 0x20000`: casting this leaves the melee and ranged swing timers alone.
+ /// Named for ClassicAPI's `C_Spell.ResetsMeleeSwing`, which is its complement for a spell
+ /// that is cast at all; [`Self::ranged_speed_cooldown`] already tests the same bit for the
+ /// shot-timer cooldown.
+ pub fn do_not_reset_combat_timers(&self) -> bool {
+ self.attributes_ex2 & ATTR_EX2_DO_NOT_RESET_COMBAT_TIMERS != 0
+ }
+
/// The spellbook's add gate (`AddSpell` `0x5e9c20`, `0x4b25b0`): a spell stays out for
/// `Attributes` `0x80` or `0x20`, or a nonzero `castUI`; passive alone does not hide it.
pub fn in_spellbook(&self) -> bool {
diff --git a/crates/benilla-ui/src/script/compat/classic_api/item.rs b/crates/benilla-ui/src/script/compat/classic_api/item.rs
index f533ac4e..a3d7bc1e 100644
--- a/crates/benilla-ui/src/script/compat/classic_api/item.rs
+++ b/crates/benilla-ui/src/script/compat/classic_api/item.rs
@@ -726,6 +726,52 @@ pub(super) fn install(lua: &Lua) -> mlua::Result
{
})?,
)?;
+ // `GetCurrentItemLevel(ItemLocation)` -> the item's level. 1.12 keeps it on the template
+ // (`ItemTemplateView::item_level`) and answers it nowhere in Lua at all: the tooltip prints
+ // it and `GetItemInfo` does not return it, so an addon comparing two pieces of gear has no
+ // number to compare. `nil` for an empty location or an uncached template, which is the same
+ // answer `IsItemDataCached` reports for it — never 0, which reads as a real item level.
+ t.set(
+ "GetCurrentItemLevel",
+ lua.create_function(|lua, v: Value| {
+ let model = lua.app_data_ref::().expect("model app_data");
+ Ok(item_loc(&v)
+ .and_then(|loc| id_at_loc(&model, &loc))
+ .and_then(|id| model.item_templates.get(&id))
+ .map(|t| i64::from(t.item_level)))
+ })?,
+ )?;
+
+ // `RequestLoadItemDataByID(id)`: start the server round trip for an item nothing has read
+ // yet. The ask-once queue `ItemMixin:ContinueOnItemLoad` already drives
+ // (`Model::item_stat_asks`, drained by `ui_items/feed.rs`), so a caller that asks and then
+ // polls `IsItemDataCachedByID` gets the modern contract out of the machinery that exists.
+ // Nothing is queued for an id already cached, which is what makes it safe to call per frame.
+ t.set(
+ "RequestLoadItemDataByID",
+ lua.create_function(|lua, v: Value| {
+ let Some(id) = item_ref(&v).filter(|id| *id != 0) else {
+ return Ok(());
+ };
+ let mut model = lua.app_data_mut::().expect("model app_data");
+ if !model.item_templates.contains_key(&id) {
+ model.item_stat_asks.insert(id);
+ }
+ Ok(())
+ })?,
+ )?;
+
+ // `GetWeaponEnchantInfo()`: the namespaced spelling of 1.12's own global
+ // (`script/weapon_enchant.rs`), delegated whole — six values, two triples, main hand then
+ // off hand.
+ t.set(
+ "GetWeaponEnchantInfo",
+ lua.create_function(|lua, ()| {
+ let f: mlua::Function = lua.globals().get("GetWeaponEnchantInfo")?;
+ f.call::(())
+ })?,
+ )?;
+
Ok(t)
}
diff --git a/crates/benilla-ui/src/script/compat/classic_api/quest_log.rs b/crates/benilla-ui/src/script/compat/classic_api/quest_log.rs
index 885fdb3a..4c922c19 100644
--- a/crates/benilla-ui/src/script/compat/classic_api/quest_log.rs
+++ b/crates/benilla-ui/src/script/compat/classic_api/quest_log.rs
@@ -289,6 +289,27 @@ pub(super) fn install(lua: &Lua) -> mlua::Result {
})?,
)?;
+ // The abandon pair, delegated to the stock two-step. 1.12's own flow is mark-then-confirm
+ // (`SetAbandonQuest` → `GetAbandonQuestName` → `AbandonQuest`), and the modern namespace keeps
+ // the same two halves under different names: `GetAbandonQuest` reads what is marked and
+ // `AbandonQuest` confirms it. Delegating means the mark, the "is it still in the log" search
+ // and the send keep one owner, so a namespaced abandon cannot drop a quest the stock path
+ // would have refused.
+ t.set(
+ "GetAbandonQuest",
+ lua.create_function(|lua, ()| {
+ let f: mlua::Function = lua.globals().get("GetAbandonQuestName")?;
+ f.call::(())
+ })?,
+ )?;
+ t.set(
+ "AbandonQuest",
+ lua.create_function(|lua, ()| {
+ let f: mlua::Function = lua.globals().get("AbandonQuest")?;
+ f.call::(())
+ })?,
+ )?;
+
Ok(t)
}
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 1d22432e..2746a7bc 100644
--- a/crates/benilla-ui/src/script/compat/classic_api/spell.rs
+++ b/crates/benilla-ui/src/script/compat/classic_api/spell.rs
@@ -541,6 +541,59 @@ pub(super) fn install(lua: &Lua) -> mlua::Result {
)?;
}
+ // The three melee-timing predicates and the melee auto-attack test. Every one reads a named
+ // boolean off the spell lookup rather than an attribute word (see `SpellLookup`), so the bits
+ // and their citations stay in `benilla-formats`.
+ //
+ // 1.12 answers none of these about a *spell*: `IsAttackAction`/`IsAutoRepeatAction` exist for
+ // an **action bar slot** only, so a spell the player has not placed on a bar cannot be asked
+ // at all — the same gap `IsSpellUsable` and `IsSpellInRange` fill, and the reason a condition
+ // engine wants them keyed by id.
+ for (name, read) in [
+ // `IsNextMeleeSpell(id)`: queued on the server's melee slot instead of cast — Heroic
+ // Strike, Raptor Strike, Maul.
+ ("IsNextMeleeSpell", 0u8),
+ // `IsRangedAutoAttackSpell(id)`: the auto-repeat flag, which is Auto Shot and wand Shoot.
+ ("IsRangedAutoAttackSpell", 1),
+ // `ResetsMeleeSwing(id)`: casting it restarts the swing timer.
+ ("ResetsMeleeSwing", 2),
+ ] {
+ t.set(
+ name,
+ lua.create_function(move |lua, spell_id: Option| {
+ let model = lua.app_data_ref::().expect("model app_data");
+ let d = spell_id.and_then(|id| crate::script::spellbook::spell_display(&model, id));
+ Ok(d.is_some_and(|d| match read {
+ 0 => d.on_next_swing,
+ 1 => d.auto_repeat,
+ _ => d.resets_melee_swing,
+ }))
+ })?,
+ )?;
+ }
+
+ // `IsAutoAttackSpell(id)`: the **melee** auto-attack, which in 1.12 is one specific spell
+ // rather than an attribute — id 6603, "Attack", the same constant `ui_spellbook.rs` uses for
+ // the auto-attack action slot. Not an attribute test, because no bit marks it: the ranged
+ // half above has the auto-repeat flag, and the melee half is this id.
+ t.set(
+ "IsAutoAttackSpell",
+ lua.create_function(|_, spell_id: Option| {
+ const ATTACK: u32 = 6603;
+ Ok(spell_id == Some(ATTACK))
+ })?,
+ )?;
+
+ // `IsSpellInRange(id [, unit])`: the namespaced spelling of the global nampower publishes
+ // (`compat/nampower.rs`), delegated so the range law has one owner.
+ t.set(
+ "IsSpellInRange",
+ lua.create_function(|lua, args: mlua::MultiValue| {
+ let f: mlua::Function = lua.globals().get("IsSpellInRange")?;
+ f.call::(args)
+ })?,
+ )?;
+
Ok(t)
}
@@ -897,6 +950,9 @@ mod tests {
_ => return None,
};
Some(crate::script::SpellLookup {
+ on_next_swing: false,
+ auto_repeat: false,
+ resets_melee_swing: false,
name: format!("Spell {id}"),
rank: None,
icon: None,
@@ -940,6 +996,9 @@ mod tests {
s.set_spellbook(book()); // a mage's book: 133 only, no 774
s.set_spell_probe(Box::new(|id| {
(id == 774).then(|| crate::script::SpellLookup {
+ on_next_swing: false,
+ auto_repeat: false,
+ resets_melee_swing: false,
name: "Rejuvenation".into(),
rank: Some("Rank 1".into()),
icon: Some("Interface\\Icons\\Spell_Nature_Rejuvenation".into()),
@@ -995,6 +1054,9 @@ mod tests {
// which carries the live icon substitutions the probe's raw DBC icon does not.
s.set_spell_probe(Box::new(|_| {
Some(crate::script::SpellLookup {
+ on_next_swing: false,
+ auto_repeat: false,
+ resets_melee_swing: false,
name: "WRONG".into(),
rank: None,
icon: Some("Interface\\Icons\\WRONG".into()),
@@ -1089,6 +1151,9 @@ mod tests {
// Slow Fall, whose real `Spell.dbc` row is (17056, 1) — the pair
// `benilla-formats`'s own `catalog_tests.rs` pins.
130 => Some(crate::script::SpellLookup {
+ on_next_swing: false,
+ auto_repeat: false,
+ resets_melee_swing: false,
name: "Slow Fall".into(),
rank: None,
icon: None,
@@ -1098,6 +1163,9 @@ mod tests {
}),
// A real spell that costs nothing.
133 => Some(crate::script::SpellLookup {
+ on_next_swing: false,
+ auto_repeat: false,
+ resets_melee_swing: false,
name: "Fireball".into(),
rank: Some("Rank 1".into()),
icon: None,
@@ -1141,6 +1209,9 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_spell_probe(Box::new(|id| {
(id == 130).then(|| crate::script::SpellLookup {
+ on_next_swing: false,
+ auto_repeat: false,
+ resets_melee_swing: false,
name: "Slow Fall".into(),
rank: None,
icon: None,
@@ -1174,4 +1245,85 @@ mod tests {
"the book arm must not shadow the probe's reagent list"
);
}
+ /// The melee-timing trio and the two auto-attack tests. 1.12 answers none of these about a
+ /// *spell* — `IsAttackAction`/`IsAutoRepeatAction` exist for an action-bar slot only — so a
+ /// spell the player has not placed on a bar cannot be asked at all on the stock surface.
+ #[test]
+ fn the_melee_timing_predicates_read_the_named_attribute_answers() {
+ use crate::script::SpellBookState;
+
+ let mut s = UiScript::new().unwrap();
+ // Heroic Strike: on next swing. Auto Shot: auto-repeat. Fireball: an ordinary cast that
+ // resets the swing. The probe is what carries the attributes, so it answers all three.
+ s.set_spell_probe(Box::new(|id| {
+ let mut d = crate::script::SpellLookup {
+ name: "x".into(),
+ rank: None,
+ icon: None,
+ reagents: Vec::new(),
+ dispel: 0,
+ hidden_aura: false,
+ on_next_swing: false,
+ auto_repeat: false,
+ resets_melee_swing: false,
+ };
+ match id {
+ 78 => {
+ d.name = "Heroic Strike".into();
+ d.on_next_swing = true;
+ }
+ 75 => {
+ d.name = "Auto Shot".into();
+ d.auto_repeat = true;
+ }
+ 133 => {
+ d.name = "Fireball".into();
+ d.resets_melee_swing = true;
+ }
+ 6603 => d.name = "Attack".into(),
+ _ => return None,
+ }
+ Some(d)
+ }));
+ s.set_spellbook(SpellBookState::default());
+
+ assert!(s
+ .eval::("return C_Spell.IsNextMeleeSpell(78) == true")
+ .unwrap());
+ assert!(s
+ .eval::("return C_Spell.IsNextMeleeSpell(133) == false")
+ .unwrap());
+ assert!(s
+ .eval::("return C_Spell.IsRangedAutoAttackSpell(75) == true")
+ .unwrap());
+ assert!(s
+ .eval::("return C_Spell.IsRangedAutoAttackSpell(78) == false")
+ .unwrap());
+ assert!(
+ s.eval::("return C_Spell.ResetsMeleeSwing(133) == true")
+ .unwrap(),
+ "a cast restarts the swing timer"
+ );
+ assert!(
+ s.eval::("return C_Spell.ResetsMeleeSwing(78) == false")
+ .unwrap(),
+ "an on-next-swing ability rides the swing rather than restarting it"
+ );
+ // The melee auto-attack is an id, not an attribute: 6603, the same constant the
+ // auto-attack action slot uses.
+ assert!(s
+ .eval::("return C_Spell.IsAutoAttackSpell(6603) == true")
+ .unwrap());
+ assert!(s
+ .eval::("return C_Spell.IsAutoAttackSpell(75) == false")
+ .unwrap());
+ // An id no catalog holds answers false rather than nil: every reader treats these as
+ // booleans, and `nil == false` is not true in Lua.
+ assert!(s
+ .eval::("return C_Spell.IsNextMeleeSpell(999999) == false")
+ .unwrap());
+ assert!(s
+ .eval::("return C_Spell.IsNextMeleeSpell() == false")
+ .unwrap());
+ }
}
diff --git a/crates/benilla-ui/src/script/compat/classic_api/spellbook.rs b/crates/benilla-ui/src/script/compat/classic_api/spellbook.rs
index 79c37463..fefbe488 100644
--- a/crates/benilla-ui/src/script/compat/classic_api/spellbook.rs
+++ b/crates/benilla-ui/src/script/compat/classic_api/spellbook.rs
@@ -156,6 +156,52 @@ pub(super) fn install(lua: &Lua) -> mlua::Result {
// these (`SpellDisplay::attributes`) isn't in `SpellSlotView` at all — `benilla-ui` has no
// `Spell.dbc` access, only what the app already resolved into the book. Not backed.
+ // `GetSkillLineName(index)`: the spellbook tab's own name, which 1.12 answers as
+ // `GetSpellTabInfo(index)`'s first return. Nine call sites in grom's corpus — the most-read
+ // missing member of this whole namespace family — and a pure delegation, so the tab order and
+ // the "past the end" answer keep one owner.
+ t.set(
+ "GetSkillLineName",
+ lua.create_function(|lua, index: mlua::Value| {
+ let f: mlua::Function = lua.globals().get("GetSpellTabInfo")?;
+ let vals = f.call::(index)?;
+ Ok(vals.into_iter().next().unwrap_or(mlua::Value::Nil))
+ })?,
+ )?;
+
+ // `IsAutoAttackSpellBookItem(slot, bookType)`: whether a book slot holds the melee
+ // auto-attack. 1.12's own `IsAttackAction` asks it of an **action bar slot**, so a book slot
+ // has no stock test at all. Resolved through the slot's spell id against the same id
+ // `C_Spell.IsAutoAttackSpell` tests, so the two cannot disagree about what auto-attack is.
+ t.set(
+ "IsAutoAttackSpellBookItem",
+ lua.create_function(|lua, (slot, book): (mlua::Value, mlua::Value)| {
+ const ATTACK: u32 = 6603;
+ // By **id**, out of the model's own slot list: the slot's *name* is not enough, since
+ // two spells can share one, and `GetSpellName` would answer the display text rather
+ // than what the slot holds.
+ let model = lua
+ .app_data_ref::()
+ .expect("model app_data");
+ let is_pet =
+ matches!(&book, mlua::Value::String(s) if s.to_str().is_ok_and(|b| &*b == "pet"));
+ let idx = match &slot {
+ mlua::Value::Integer(i) => usize::try_from(*i).ok(),
+ mlua::Value::Number(n) if n.fract() == 0.0 => usize::try_from(*n as i64).ok(),
+ _ => None,
+ };
+ let slots = if is_pet {
+ &model.pet_book.slots
+ } else {
+ &model.spellbook.slots
+ };
+ Ok(idx
+ .and_then(|i| i.checked_sub(1))
+ .and_then(|i| slots.get(i))
+ .is_some_and(|s| s.spell_id == ATTACK))
+ })?,
+ )?;
+
Ok(t)
}
diff --git a/crates/benilla-ui/src/script/compat/nampower.rs b/crates/benilla-ui/src/script/compat/nampower.rs
index f70ab731..26a60ed4 100644
--- a/crates/benilla-ui/src/script/compat/nampower.rs
+++ b/crates/benilla-ui/src/script/compat/nampower.rs
@@ -2951,6 +2951,9 @@ mod tests {
);
s.set_spell_probe(Box::new(|id| {
Some(crate::script::SpellLookup {
+ on_next_swing: false,
+ auto_repeat: false,
+ resets_melee_swing: false,
name: format!("Spell {id}"),
rank: None,
icon: None,
diff --git a/crates/benilla-ui/src/script/compat/superwow.rs b/crates/benilla-ui/src/script/compat/superwow.rs
index e204f192..323d8483 100644
--- a/crates/benilla-ui/src/script/compat/superwow.rs
+++ b/crates/benilla-ui/src/script/compat/superwow.rs
@@ -610,6 +610,9 @@ mod option_verbs {
let mut s = UiScript::new().unwrap();
s.set_spell_probe(Box::new(|id| {
(id == 774).then(|| crate::script::SpellLookup {
+ on_next_swing: false,
+ auto_repeat: false,
+ resets_melee_swing: false,
name: "Rejuvenation".into(),
rank: Some("Rank 1".into()),
icon: Some("Interface\\Icons\\Spell_Nature_Rejuvenation".into()),
diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs
index a6b8dfbc..034f90af 100644
--- a/crates/benilla-ui/src/script/model.rs
+++ b/crates/benilla-ui/src/script/model.rs
@@ -56,6 +56,20 @@ pub struct SpellLookup {
/// `ui_aura::shown_in_aura_ui` applies to the buff and debuff lists, so this answers for the
/// same auras those omit. Backs nampower's `IsAuraHidden`.
pub hidden_aura: bool,
+ /// `Attributes & 0x404`: queued on the server's melee slot for the next swing rather than
+ /// cast (`SpellDisplay::on_next_swing`). Backs `C_Spell.IsNextMeleeSpell`.
+ ///
+ /// **A named answer, not the raw attribute word.** Every bit behind these three is a fact
+ /// `benilla-formats` already names against its own reference citation; handing Lua the dword
+ /// would move that knowledge to the one place it cannot be checked.
+ pub on_next_swing: bool,
+ /// `AttributesEx2 & 0x20`, the auto-repeat flag: Auto Shot and wand Shoot. Backs
+ /// `C_Spell.IsRangedAutoAttackSpell`.
+ pub auto_repeat: bool,
+ /// Whether casting this restarts the melee swing timer: `AttributesEx2 & 0x20000`
+ /// (DO_NOT_RESET_COMBAT_TIMERS) clear **and** not on-next-swing — one of those rides the
+ /// swing rather than restarting it. Backs `C_Spell.ResetsMeleeSwing`.
+ pub resets_melee_swing: bool,
}
/// The host's answer for *any* spell id, learned or not: `Spell.dbc`'s whole display table, which
diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs
index 5b107db9..85d41519 100644
--- a/crates/benilla-ui/src/script/spellbook.rs
+++ b/crates/benilla-ui/src/script/spellbook.rs
@@ -380,6 +380,12 @@ pub(super) fn spell_display(model: &Model, spell_id: u32) -> Option UiScript {
});
s.set_spell_probe(Box::new(|id| {
(id == MIND_FLAY).then(|| crate::script::SpellLookup {
+ on_next_swing: false,
+ auto_repeat: false,
+ resets_melee_swing: false,
name: "Mind Flay".into(),
rank: Some("Rank 1".into()),
icon: Some("Interface\\Icons\\Spell_Shadow_SiphonMana".into()),
diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs
index 59c3ca1a..b0a5b697 100644
--- a/crates/benilla-ui/src/script/tests/reference_surface.rs
+++ b/crates/benilla-ui/src/script/tests/reference_surface.rs
@@ -322,6 +322,14 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
"C_EncodingUtil",
"C_EquipmentSet",
"C_LootHistory",
+ // ClassicAPI's `C_Spell.IsNextMeleeSpell`/`IsRangedAutoAttackSpell`/`IsAutoAttackSpell`/
+ // `ResetsMeleeSwing` and `C_SpellBook.IsAutoAttackSpellBookItem`: 1.12 answers all five
+ // only about an **action bar slot** (`IsAttackAction`, `IsAutoRepeatAction`), so a spell
+ // the player knows but has not placed on a bar cannot be asked at all — the same gap
+ // `IsSpellUsable` and `IsSpellInRange` fill. `C_Item.GetCurrentItemLevel` is a number
+ // 1.12 prints in a tooltip and returns from nothing, so no addon can compare two pieces
+ // of gear; `C_Item.RequestLoadItemDataByID` is the modern async item load over the
+ // ask-once queue the item mixin already drives.
// ClassicAPI's detection pair (script/compat/classic_api/mod.rs): pfUI gates its whole
// load on `CLASSIC_API_VERSION`, so this one is load-bearing, not cosmetic.
"CLASSIC_API_VERSION",
diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md
index c596317b..a18caf2d 100644
--- a/docs/ADDON_COMPAT.md
+++ b/docs/ADDON_COMPAT.md
@@ -228,7 +228,7 @@ separately, so "cannot say" has to be distinguishable from "no".
| `EventRegistry`, `CallbackRegistryMixin:RegisterFrameEvent`/`RegisterFrameEventAndCallback` | done | `compat/classic_api/callback_registry.rs`. The bridge from real frame events into the callback bus; pfUI's new-item tracker is `EventRegistry:RegisterFrameEventAndCallback("BAG_NEW_ITEMS_UPDATED", ...)` (`modules/newitem.lua:60`). 1.12's `OnEvent` takes **no parameters** — the handler reads the globals `event`/`arg1..arg9` with `this` as the frame — so the forwarder cannot be upstream's `function(_, event, ...)`. It is defined at **chunk scope** on purpose: written as a closure inside `RegisterFrameEvent` it would capture that function's `event` parameter and shadow the per-fire global, making the frame report every event under the name of whichever registered first. A two-event test catches exactly that and was verified to fail against the closure version (`left: (1, 0)` — the wrong callback fired) |
| `GameTooltip_Hide` | done | `compat/classic_api/tooltip.rs`. A later-client FrameXML helper: `reference/1.12-globals.tsv` lists exactly six `GameTooltip_*` functions and this is not among them. Its purpose is to be passed *by name* as an `OnLeave` handler, which is all five of its call sites in the installed set — and nil there is not a no-op, it raised inside `HookScript` and took the rest of the module with it (`modules/friendnotes.lua:90`). Checked at the same time: `SetItemButtonTexture`/`SetItemButtonCount`, also called by the addons, **are** genuine 1.12 FrameXML globals and need nothing |
| `C_Spell.GetSpellReagents` | done | `compat/classic_api/spell.rs`, over new `SpellLookup::reagents` — `Spell.dbc`'s `Reagent[8]`/`ReagentCount[8]`, which `benilla-formats` already parses (`spells/display.rs:122`, with `catalog_tests.rs` pinning Slow Fall's `(17056, 1)`). Empty columns are dropped at the feed, so `reagents[1]` is the first *real* reagent. Returns an empty table for a spell that needs nothing and **nil** for an unknown id — genuinely different answers, both of which pfUI's `if reagents and reagents[1]` reads correctly (`modules/actionbar.lua:1553`). The subtle part: `spellbook.rs`'s `spell_display` resolves a *known* spell from the book, and a book slot carries no reagent columns — so the book arm now takes reagents from the probe while keeping the book's name/rank/icon. Without that, the spells most likely to be asked about (the ones on your bars) would be exactly the ones wrongly reporting no reagents; a test pins it and was verified to fail otherwise |
-| **ClassicAPI tail — the measured remainder** | todo | A full sweep of every `C_.` the installed addons reference, minus everything built, leaves **46 members across 15 namespaces**. Recorded here so the next pass starts from measurement rather than guesswork. Grouped by what each actually needs: **pure algorithms, no client state** — `C_EncodingUtil` (`CompressString`/`DecompressString`/`EncodeBase`/`DecodeBase`/`SerializeJSON`/`DeserializeJSON`/`SerializeCBOR`/`DeserializeCBOR`), self-contained and mechanical. **Needs a live feed** — `C_Spell.UnitCastingInfo`/`UnitChannelInfo`/`ChannelInfo` (per-unit cast state; pfUI's real cast bars and nameplate cast bars depend on these), `C_Spell.IsSpellInRange`/`IsNextMeleeSpell`/`IsRangedAutoAttackSpell`/`ResetsMeleeSwing`/`GetSpellDispelType`. **Needs a 1.12-absent subsystem** — `C_EquipmentSet` (13 members; no equipment sets exist in 1.12 at all), `C_LootHistory` (3), `C_NewItems` (3), `C_PlayerCache` (2). **Small, data-backed** — `C_Item.GetCurrentItemLevel`/`GetItemUniquenessByID`, `C_FriendList.SetFriendNotes`, `C_QuestLog.GetQuestDetails`, `C_PlayerInfo.CanUseItem`, `C_Map.GetMapOverlays`, `C_ChatBubbles.GetAllChatBubbles`. (`C_Button.backdrop` is a field access on an addon's own table, not a namespace — a false positive of the sweep.) |
+| **ClassicAPI tail — the measured remainder** | **6 left of 56** | Re-measured from scratch rather than trusted: of the **153** distinct `C_.` names grom's addons reference, a fresh VM answered all but **56** — 24 where the namespace existed and the member did not, and **32 where the whole namespace was absent**, which is the half that *raises* rather than returning nil. (The earlier sweep's "46 across 15 namespaces" undercounted, and its own grouping had `C_Spell.UnitCastingInfo` down as missing when it was built.) Fifty are now in, in four commits: `C_ChatInfo`, `C_LossOfControl`, `C_EncodingUtil`, `C_EquipmentSet`, `C_LootHistory`, the seven `C_AddOns` members, five on `C_Spell`, two on `C_SpellBook`, three on `C_Item` and the `C_QuestLog` abandon pair. **The six that remain, each with what it needs:** `C_Map.GetAreaTriggerInfo` (pfQuest reads trigger geometry from it instead of a shipped coordinate table — `AreaTrigger.dbc` *is* parsed in `benilla-formats`, so this is a host probe plus the world-point-to-zone resolution `GetMapPositionWorldLoc` already inverts); `C_Item.GetItemSetInfo` (likewise — `ItemSet.dbc` is parsed, and SuperCleveRoids documents the exact table shape it wants, so this is a probe); `C_Item.GetEnchantInfo` and `C_Item.UseAtCursor` (no caller in the corpus beyond the name); `C_FriendList.SetFriendNotes` (1.12 has **no note field on the wire at all** — notes arrived in 2.x — so a note the player typed would live until logout and then be gone, which is worse than a button that does nothing); and `C_BattleNet.SendGameData`, which stays **deliberately absent**: every caller guards it (`_G.C_BattleNet and _G.C_BattleNet.SendGameData or _G.BNSendGameData`), and registering it would claim a BattleNet this client does not have, so the guard is doing exactly its job |
| `select` is nil, and real addons call it | open question, maintainer's call | Evidence for the standing stdlib-restore question in `lua_ext.rs`/`string_methods.rs`, not a new one. `lua50.rs` keeps `select` nil for 1.12 fidelity, and pfUI calls `select(7, GetTotemInfo(id))` in live handlers (`modules/totems.lua:18`, `:32`) — so on a real OctoWoW install, where ClassicAPI backports the 5.1 stdlib, those work and here they raise. Currently unreachable (the totem bar is always empty, see that row), so it costs nothing *today*; it is logged because it is the same decision as the string-metatable deviation and should be settled once, for the whole corpus, rather than per call site. Also confirmed while working here: **`#` does not parse in this VM at all**, so any chunk written for it must use `table.getn` |
| Lua 5.1 stdlib: `select`, `string.match`, `string.gmatch`, `math.fmod`, `math.modf` | done — **decision taken** | `compat/classic_api/stdlib_backport.rs`. This **resolves** the standing open question above, decided by the maintainer on live evidence (pfUI's `modules/marktracking.lua:300` uses `string.match` to parse a raid-target token and raised 8 times in one session). Scoped by **measurement**, not wholesale: these are the only five members any installed addon calls (12/7/5/2/1 sites). Deliberately still absent — `string.reverse`, `strrev`, `table.maxn`, `math.huge`, the hyperbolics, `coroutine`: nothing calls them, and `table.maxn` would fight `lua50.rs`'s `n`-based table library, which is 5.0 semantics this client needs. **Lives in `compat/`, not `lua50.rs`**: the dialect layer keeps describing 1.12 truthfully and compat re-adds what ClassicAPI adds, so a build that drops `compat/` is still strict 1.12. The restored functions are the **real 5.1 originals**, stashed under `lua50::REG_LUA51_PREFIX` before nil-ing — nothing is reimplemented, because a hand-rolled `string.match` would be a second, subtly different pattern engine, and a test proves the genuine one is in place (position captures, the non-greedy `-` quantifier). **Accepted cost, stated plainly**: an addon writing `if string.gmatch then` now takes the modern branch — which on this server, with ClassicAPI loaded, is the correct answer. Four fidelity gates updated rather than relaxed: `lua50.rs`'s own test now asserts the dialect still lists these on `REMOVED` *and* that they are reachable in the finished VM, so "compat restores it" is not licence for the dialect to stop removing it |
| `SetOwner: unknown anchor 'BOTTOMRIGHT'` | **unexplained — diagnostic added** | 566 occurrences in one live session, the loudest line in the log by 20×, and the cause is **not yet known**. Ruled out: every `SetOwner` call site in every installed addon `.lua` and `.xml` passes a valid `ANCHOR_*` string (verified with a paren-matching scan, after a naive regex truncated nested calls and gave a false reading); `script/tooltip/verbs.rs` does no prefix-stripping, matching the full uppercased name; and it does **not** reproduce under the addon harness, so it needs real in-world hovering. It is a *warning*, not an error — the anchor falls back to `ANCHOR_LEFT` and the tooltip still draws, just anchored wrongly. The warning now names the **owner frame**, which distinguishes an addon caller from the stock FrameXML off the player's patch chain; the next session log should identify it. Note `"ANCHOR_TOP"` (`pfUI/modules/mapreveal.lua:44`) and `"NONE"` (`modules/buffwatch.lua:117`) are separately invalid in 1.12 — the reference has nine anchor modes and neither is among them — so defaulting those to Left matches the reference's own silent behaviour |