classicapi: fourteen members over four namespaces — the measured tail down to six

Of the 153 `C_<Namespace>.<Member>` names grom's addons reference, the
fresh VM was missing 56; this takes it to 6. Fourteen members here, each
over data or a stock verb that already existed:

`C_Spell` gains the four melee-timing predicates and `IsSpellInRange`.
1.12 answers all of those only about an ACTION BAR SLOT — `IsAttackAction`
and `IsAutoRepeatAction` — so a spell the player knows but has not placed
on a bar cannot be asked at all, which is the gap a condition engine
keeps running into. The three attribute answers ride named booleans on the
spell lookup rather than a raw attribute word, so the bits and their
reference citations stay in `benilla-formats` where they can be checked;
`do_not_reset_combat_timers` is named there for the first time, beside the
`ranged_speed_cooldown` that already tested the same bit. The melee
auto-attack is an ID (6603) rather than an attribute, because no bit marks
it — the ranged half has the auto-repeat flag and the melee half is that
spell.

`C_SpellBook.GetSkillLineName` is the most-read missing member of the
whole family (nine call sites) and is one delegation to `GetSpellTabInfo`.
`IsAutoAttackSpellBookItem` resolves by id against the same 6603, so the
two cannot disagree about what auto-attack is.

`C_Item.GetCurrentItemLevel` answers a number 1.12 prints in a tooltip
and returns from nothing, so no addon can compare two pieces of gear.
`RequestLoadItemDataByID` is the modern async load over the ask-once queue
`ItemMixin:ContinueOnItemLoad` already drives. `GetWeaponEnchantInfo` is a
pass-through.

`C_QuestLog`'s abandon pair delegates to the stock two-step, so the mark,
the still-in-the-log search and the send keep one owner — a namespaced
abandon cannot drop a quest the stock path would have refused.

The six left are each written down with what they need. Two are a host
probe over a DBC `benilla-formats` already parses (`AreaTrigger` for
pfQuest's trigger geometry, `ItemSet`), two have no caller in the corpus,
`SetFriendNotes` has no note field on the 1.12 wire at all — a note typed
and lost at logout is worse than a button that does nothing — and
`C_BattleNet.SendGameData` stays deliberately absent, because every caller
guards it and registering it would claim a BattleNet we do not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-25 18:00:13 -06:00
co-authored by Claude Opus 5
parent 7bfdefb206
commit 546e60765d
13 changed files with 319 additions and 1 deletions
@@ -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(),
})
}));
}
@@ -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 {
@@ -726,6 +726,52 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
})?,
)?;
// `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::<Model>().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::<Model>().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::<mlua::MultiValue>(())
})?,
)?;
Ok(t)
}
@@ -289,6 +289,27 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
})?,
)?;
// 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::<mlua::MultiValue>(())
})?,
)?;
t.set(
"AbandonQuest",
lua.create_function(|lua, ()| {
let f: mlua::Function = lua.globals().get("AbandonQuest")?;
f.call::<mlua::MultiValue>(())
})?,
)?;
Ok(t)
}
@@ -541,6 +541,59 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
)?;
}
// 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<u32>| {
let model = lua.app_data_ref::<Model>().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<u32>| {
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::<mlua::MultiValue>(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::<bool>("return C_Spell.IsNextMeleeSpell(78) == true")
.unwrap());
assert!(s
.eval::<bool>("return C_Spell.IsNextMeleeSpell(133) == false")
.unwrap());
assert!(s
.eval::<bool>("return C_Spell.IsRangedAutoAttackSpell(75) == true")
.unwrap());
assert!(s
.eval::<bool>("return C_Spell.IsRangedAutoAttackSpell(78) == false")
.unwrap());
assert!(
s.eval::<bool>("return C_Spell.ResetsMeleeSwing(133) == true")
.unwrap(),
"a cast restarts the swing timer"
);
assert!(
s.eval::<bool>("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::<bool>("return C_Spell.IsAutoAttackSpell(6603) == true")
.unwrap());
assert!(s
.eval::<bool>("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::<bool>("return C_Spell.IsNextMeleeSpell(999999) == false")
.unwrap());
assert!(s
.eval::<bool>("return C_Spell.IsNextMeleeSpell() == false")
.unwrap());
}
}
@@ -156,6 +156,52 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
// 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::<mlua::MultiValue>(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::<crate::script::Model>()
.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)
}
@@ -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,
@@ -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()),
+14
View File
@@ -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
@@ -380,6 +380,12 @@ pub(super) fn spell_display(model: &Model, spell_id: u32) -> Option<super::Spell
// would claim every known spell's aura is shown. A probe miss keeps `false`, which is
// what the aura lists do with a catalog miss too.
hidden_aura: probed.as_ref().is_some_and(|d| d.hidden_aura),
// The melee-timing trio is the probe's for the same reason: a book slot carries no
// attribute columns at all. A probe miss leaves every one false, which reads as "an
// ordinary cast" — the same direction the aura flag above fails in.
on_next_swing: probed.as_ref().is_some_and(|d| d.on_next_swing),
auto_repeat: probed.as_ref().is_some_and(|d| d.auto_repeat),
resets_melee_swing: probed.as_ref().is_some_and(|d| d.resets_melee_swing),
});
}
model.spell_probe.as_ref().and_then(|probe| probe(spell_id))
@@ -49,6 +49,9 @@ fn harness() -> 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()),
@@ -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",
+1 -1
View File
@@ -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_<Namespace>.<Member>` 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_<Namespace>.<Member>` 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 |