From 5555bf9c895dfcbaaf970295d11a1fb17a75747b Mon Sep 17 00:00:00 2001 From: grom Date: Fri, 23 Oct 2026 21:23:41 -0600 Subject: [PATCH] ui+app: nampower's bag group, aura cancels, no-queue casts, unit-event CVars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second tranche. Eight more verbs and six CVar rows, all from state this client already owns. The bag group — `FindPlayerItemSlot(idOrName)`, `GetBagItem(bag, slot)`, `GetItemIdCooldown(itemId)`, `UseItemIdOrName(idOrName)` — shares one lookup, deliberately: the three that search must agree about *which* of two identical stacks they mean, and they answer the first, reading bags and slots ascending, as the reference's own auto-store search walks (`0x4f8030`) and as a player reads their bags. A name matches through the item cache, case-insensitively, since a bag slot carries no name of its own. `GetItemIdCooldown` answers the stock `0, 0, 1` for a cold or uncarried item rather than nil, because the consumer does arithmetic on the triple. `UseItemIdOrName` calls `UseContainerItem` itself rather than reaching past it, so the cursor-clear and repair-mode rules the stock verb applies still happen. `CancelPlayerAuraSpellId(spellId, [ignoreMissing])` and `CancelPlayerAuraSlot(auraSlot)`: the two keys into the `CMSG_CANCEL_AURA` queue `CancelPlayerBuff` already fills, so the three cannot disagree. The slot is the **raw 0-based** descriptor slot upstream documents and the buff events report, not the Lua index. `ignoreMissing` is accepted and changes nothing — it asks upstream not to complain, and nothing here would have complained. `CastSpellNoQueue(spellId, spellBook [, unit])` and `CastSpellByNameNoQueue(name [, unit])`: the plain cast, which is the honest mapping and not a stub — benilla has no spell queue to bypass, so "cast this now, do not queue it" is exactly what happens. The point of them is the first argument: a **spell id** where stock `CastSpell` takes a book slot. `"mouseover"` routes to the queue SuperWoW's `CastSpellByName(name, "CLICK")` already uses. The six `NP_EnableUnitEventsX` switches are registered stored-only, one step further back than the four `NP_Enable*` rows beside them: benilla raises no GUID-keyed unit event at all, so there is no stream for a token filter to narrow. Registering them stops the unknown-CVar warning and lets `SuperCleveRoidMacros`'s writes persist; it claims nothing about the events arriving, and `cvars.rs`'s own reader gate carries that reason. All nine gates green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/src/cvars.rs | 71 +++ .../benilla-ui/src/script/compat/nampower.rs | 413 ++++++++++++++++++ .../src/script/tests/reference_surface.rs | 17 + 3 files changed, 501 insertions(+) diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 850311eb..b8379978 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -1281,6 +1281,49 @@ pub(crate) const REGISTERED: &[Registered] = &[ "nampower v2 addon-compat, STORED ONLY: benilla fires no `SPELL_START_SELF`/`_OTHER` — \ their shape wants itemId/spellType/corpseOwnerGuid columns this client does not carry", ), + // The six `NP_EnableUnitEventsX` switches (nampower v2.33+): which unit tokens fire the + // GUID-keyed unit events, so an addon can cut the stream to the units it watches. + // `SuperCleveRoidMacros`'s `NampowerAPI.lua` writes all six at setup. + // + // STORED ONLY, and the reason is one step further back than the other `NP_Enable*` rows: + // benilla fires no GUID-keyed unit event at all — no `UNIT_HEALTH_GUID`, `UNIT_MANA_GUID`, + // `UNIT_RAGE_GUID`, `UNIT_ENERGY_GUID` or `UNIT_COMBAT_GUID` — so there is no stream for a + // filter to narrow. Registering them is what stops the engine's unknown-CVar warning and lets + // the addon's writes persist; it is not a claim that the events arrive. Defaults are + // upstream's own documented `0`. + ours( + "NP_EnableUnitEventsGuid", + "0", + "nampower v2 addon-compat, STORED ONLY: benilla fires no GUID-keyed unit event, so there \ + is no stream for this switch to open", + ), + ours( + "NP_EnableUnitEventsGuidFiltering", + "0", + "nampower v2 addon-compat, STORED ONLY: the filter half of `NP_EnableUnitEventsGuid`, and \ + there is nothing to filter", + ), + ours( + "NP_EnableUnitEventsMouseover", + "0", + "nampower v2 addon-compat, STORED ONLY: which tokens fire the GUID-keyed unit events, \ + none of which this client raises", + ), + ours( + "NP_EnableUnitEventsParty", + "0", + "nampower v2 addon-compat, STORED ONLY: see `NP_EnableUnitEventsMouseover`", + ), + ours( + "NP_EnableUnitEventsPet", + "0", + "nampower v2 addon-compat, STORED ONLY: see `NP_EnableUnitEventsMouseover`", + ), + ours( + "NP_EnableUnitEventsRaid", + "0", + "nampower v2 addon-compat, STORED ONLY: see `NP_EnableUnitEventsMouseover`", + ), ours( "NP_EnableSpellGoEvents", "0", @@ -4898,6 +4941,34 @@ mod tests { "NP_EnableSpellHealEvents", "pfUI libs/libdebuff.lua writes it at PLAYER_LOGIN; no SPELL_HEAL_* here", ), + // The six unit-event switches: `SuperCleveRoidMacros`'s `NampowerAPI.lua` writes all + // six at setup. One step further back than the four above — there is no GUID-keyed + // unit event here for a token filter to narrow, so nothing could read them even in + // principle until that family exists. + ( + "NP_EnableUnitEventsGuid", + "SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here", + ), + ( + "NP_EnableUnitEventsGuidFiltering", + "SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here", + ), + ( + "NP_EnableUnitEventsMouseover", + "SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here", + ), + ( + "NP_EnableUnitEventsParty", + "SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here", + ), + ( + "NP_EnableUnitEventsPet", + "SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here", + ), + ( + "NP_EnableUnitEventsRaid", + "SuperCleveRoidMacros NampowerAPI.lua writes it; no GUID-keyed unit events here", + ), // SuperWoW-era plate cast bar: pfUI's nameplates module writes 0 at load. benilla's // V-key plates draw no cast bar, so nothing here reads it — see the row's own note. ( diff --git a/crates/benilla-ui/src/script/compat/nampower.rs b/crates/benilla-ui/src/script/compat/nampower.rs index 54b809c7..94071ae1 100644 --- a/crates/benilla-ui/src/script/compat/nampower.rs +++ b/crates/benilla-ui/src/script/compat/nampower.rs @@ -177,6 +177,52 @@ fn equipped_table(lua: &Lua, view: &crate::script::InvSlotView) -> mlua::Result< /// expect — BetterCharacterPanel wraps exactly these three getters in `pcall` /// (`BetterCharacterPanel-lib.lua:177`, `:198`, `:252`) while calling `GetEquippedItem` bare, which /// is only worth doing for a function that can raise. +/// The first bag slot holding `ident` — a number is an item id, a string a case-insensitive name +/// match — ascending bag then ascending slot, which is the order the reference's own auto-store +/// search walks (`0x4f8030`) and the order a player reads their bags in. Backs +/// `FindPlayerItemSlot`, `GetItemIdCooldown` and `UseItemIdOrName`, which must all agree about +/// *which* of two identical stacks they mean. +fn find_bag_slot(model: &crate::script::Model, ident: Option<&Value>) -> Option<(i64, u32)> { + let wanted_id = match ident { + Some(Value::Integer(n)) => u32::try_from(*n).ok(), + Some(Value::Number(n)) => (*n >= 0.0).then_some(*n as u32), + _ => None, + }; + let wanted_name = match ident { + Some(Value::String(s)) => Some(s.to_str().ok()?.to_lowercase()), + _ => None, + }; + if wanted_id.is_none_or(|id| id == 0) && wanted_name.is_none() { + return None; + } + // 0 is the backpack, 1..=4 the equipped bags: `GetContainerItemInfo`'s own numbering. + for bag in 0i64..=4 { + let container = model.containers.get(&bag)?; + let mut slots: Vec = container.slots.keys().copied().collect(); + slots.sort_unstable(); + for slot in slots { + let Some(s) = container.slots.get(&slot).filter(|s| s.item_id != 0) else { + continue; + }; + let hit = match (&wanted_id, &wanted_name) { + (Some(id), _) => s.item_id == *id, + // The slot carries no name of its own; the cache row it points at does. An + // uncached item cannot be matched by name, which is the same limit every + // name-taking verb here has. + (_, Some(name)) => model + .item_templates + .get(&s.item_id) + .is_some_and(|t| t.name.to_lowercase() == *name), + _ => false, + }; + if hit { + return Some((bag, slot)); + } + } + } + None +} + /// `INVSLOT_TRINKET1`/`INVSLOT_TRINKET2`, the two slots `GetTrinkets` reports. const TRINKET_SLOTS: [usize; 2] = [13, 14]; @@ -738,6 +784,206 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; + // `CancelPlayerAuraSpellId(spellId, [ignoreMissing])` and `CancelPlayerAuraSlot(auraSlot)` + // (v2.35+): the two ways to drop one of the player's own auras without first finding its + // 1-based buff index, which is what stock `CancelPlayerBuff` demands and what makes it awkward + // from a macro. Both queue the spell id onto the same `CMSG_CANCEL_AURA` queue + // `CancelPlayerBuff` and `CancelUnitBuff` use, so the three cannot disagree. + // + // `auraSlot` is the **raw 0-based** descriptor slot, as upstream states and as the buff and + // debuff events report it — not the Lua index. `AuraState::slot` is that number. + // + // No return value on any path, as `CancelPlayerBuff` has none, and a missing aura is a silent + // no-op whatever `ignoreMissing` says: the flag asks upstream not to complain, and there is + // nothing here that would have complained. Accepted and ignored rather than refused, so a + // caller passing it is not surprised. + lua.globals().set( + "CancelPlayerAuraSpellId", + lua.create_function( + |lua, (spell_id, _ignore_missing): (Option, Option)| { + let Some(spell_id) = spell_id.filter(|&id| id != 0) else { + return Ok(()); + }; + let mut model = lua + .app_data_mut::() + .expect("model app_data"); + if model + .auras + .get("player") + .is_some_and(|l| l.iter().any(|a| a.spell_id == spell_id && a.cancelable)) + { + model.cancel_aura_requests.push(spell_id); + } + Ok(()) + }, + )?, + )?; + + lua.globals().set( + "CancelPlayerAuraSlot", + lua.create_function(|lua, aura_slot: Option| { + let Some(aura_slot) = aura_slot else { + return Ok(()); + }; + let mut model = lua + .app_data_mut::() + .expect("model app_data"); + let found = model.auras.get("player").and_then(|l| { + l.iter() + .find(|a| u32::from(a.slot) == aura_slot && a.cancelable) + .map(|a| a.spell_id) + }); + if let Some(spell_id) = found { + model.cancel_aura_requests.push(spell_id); + } + Ok(()) + })?, + )?; + + // `CastSpellNoQueue(spellId, spellBook [, unit])` (v3.1+) and `CastSpellByNameNoQueue(name + // [, unit])` (v2.0+): cast without entering nampower's spell queue. + // + // **benilla has no spell queue to bypass**, so these are the plain cast — and that is the + // honest mapping rather than a stub, because what the caller asked for ("cast this now, do not + // queue it") is exactly what happens here. `spell/inflight.rs` carries namreeb's always-on + // cast-ready rule, not pepo's queue windows; `docs/ADDON_COMPAT.md` records why the queue is + // unbuilt. + // + // Unlike stock `CastSpell`, the first argument is a **spell id**, not a book slot — upstream's + // whole reason for the function. The id goes on the same queue `CastSpell` fills, so a cast + // from here and one from a bar are the same send. `"mouseover"` routes to the mouseover queue + // SuperWoW's `CastSpellByName(name, "CLICK")` already uses; any other token takes the + // current-selection path, which is what our `CastSpellByName` does with a token today — no + // new deviation, and `docs/ADDON_COMPAT.md` has the row. + for name in ["CastSpellNoQueue", "CastSpellByNameNoQueue"] { + let by_name = name == "CastSpellByNameNoQueue"; + lua.globals().set( + name, + lua.create_function(move |lua, (first, second, third): (Value, Value, Value)| { + let mut model = lua + .app_data_mut::() + .expect("model app_data"); + // `CastSpellByNameNoQueue(name, unit)` puts the unit second; + // `CastSpellNoQueue(id, book, unit)` third. + let unit = if by_name { &second } else { &third }; + let mouseover = matches!(unit, Value::String(s) + if s.to_str().ok().as_deref() == Some("mouseover")); + // `spell_ident` is the number-or-name resolver every other verb in this file + // uses, so a name here resolves exactly as `IsSpellUsable`'s does: through the + // player's own book, highest rank first. + let spell_id = spell_ident(&model, Some(&first)); + if let Some(spell_id) = spell_id.filter(|&id| id != 0) { + if mouseover { + model.mouseover_spell_casts.push(spell_id); + } else { + model.spell_casts.push(spell_id); + } + } + Ok(()) + })?, + )?; + } + + // `UseItemIdOrName(itemIdOrName)` (v2.14+): use the first bag slot holding that item, through + // the same queue `UseContainerItem` fills, so the cursor and repair-mode rules the stock verb + // applies are not bypassed. Nothing carried is a silent no-op — the consumer's macro is + // allowed to name an item the player does not have. + lua.globals().set( + "UseItemIdOrName", + lua.create_function(|lua, ident: Option| { + let found = { + let model = lua + .app_data_ref::() + .expect("model app_data"); + find_bag_slot(&model, ident.as_ref()) + }; + if let Some((bag, slot)) = found { + let use_item: mlua::Function = lua.globals().get("UseContainerItem")?; + use_item.call::<()>((bag, slot))?; + } + Ok(()) + })?, + )?; + + // `FindPlayerItemSlot(itemIdOrName)` (v2.14+) -> `bag, slot`, or nil. Bag `0` is the backpack + // and `1..4` the equipped bags, which is `GetContainerItemInfo`'s own numbering. + // + // Ascending bag then ascending slot, which is the order the reference's own auto-store search + // walks (`0x4f8030`) and so the order a player reads their bags in: two stacks of the same + // item answer the first, not an arbitrary one. A number argument is an item id; a string is + // matched against the slot's name, case-insensitively, because the consumer passes whatever + // the macro said and 1.12 item names are display strings. + lua.globals().set( + "FindPlayerItemSlot", + lua.create_function(|lua, ident: Option| { + let model = lua + .app_data_ref::() + .expect("model app_data"); + Ok(match find_bag_slot(&model, ident.as_ref()) { + Some((bag, slot)) => (Some(bag), Some(slot)), + None => (None, None), + }) + })?, + )?; + + // `GetBagItem(bag, slot)` (v2.14+) -> that slot as a table, nil for an empty or unknown one. + // The columns are the ones a bag slot actually carries here; `GetItemStats(itemId)` beside it + // is the whole cache row, so this deliberately does not re-derive template fields it would + // have to guess at. + lua.globals().set( + "GetBagItem", + lua.create_function(|lua, (bag, slot): (Option, Option)| { + let (Some(bag), Some(slot)) = (bag, slot) else { + return Ok(Value::Nil); + }; + let found = { + let model = lua + .app_data_ref::() + .expect("model app_data"); + model + .containers + .get(&bag) + .and_then(|c| c.slots.get(&slot)) + .filter(|s| s.item_id != 0) + .cloned() + }; + let Some(s) = found else { + return Ok(Value::Nil); + }; + let t = lua.create_table()?; + t.set("bag", bag)?; + t.set("slot", slot)?; + t.set("itemId", s.item_id)?; + t.set("count", s.count)?; + t.set("texture", s.texture.clone())?; + t.set("quality", s.quality)?; + t.set("link", s.link.clone())?; + t.set("locked", s.locked)?; + Ok(Value::Table(t)) + })?, + )?; + + // `GetItemIdCooldown(itemId)` (v2.14+) -> `start, duration, enable` on the `GetTime` clock, + // `GetContainerItemCooldown`'s triple for whichever bag slot holds that item. A cold or + // absent item answers `0, 0, 1`, which is what the stock verb answers for a slot with no + // cooldown — the consumer does arithmetic on these, so nil would be the wrong shape. + lua.globals().set( + "GetItemIdCooldown", + lua.create_function(|lua, item_id: Option| { + let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0); + let model = lua + .app_data_ref::() + .expect("model app_data"); + let cooldown = find_bag_slot(&model, item_id.as_ref()) + .and_then(|(bag, slot)| model.container_cooldowns.get(&(bag, slot)).copied()) + .filter(|&(start, duration, enabled)| start + duration > now || !enabled); + Ok(match cooldown { + Some((start, duration, enabled)) => (start, duration, i32::from(enabled)), + None => (0.0, 0.0, 1), + }) + })?, + )?; + // `GetItemIconTexture(itemId)` (v2.14+): the item's inventory icon as an extensionless // `Interface\Icons\…` path, the sibling of `GetSpellIconTexture` above. Two hops, because an // item template carries a *display* id and not an icon: the cache row's `displayInfoID`, then @@ -859,6 +1105,173 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { mod tests { use crate::script::UiScript; + /// The two aura cancels reach the same `CMSG_CANCEL_AURA` queue `CancelPlayerBuff` does — one + /// by spell id, one by the **raw 0-based** descriptor slot the buff events report — and both + /// refuse an aura the client would not let the player cancel. + #[test] + fn the_aura_cancels_queue_the_same_send_as_cancelplayerbuff() { + let mut s = UiScript::new().unwrap(); + let aura = |spell_id: u32, slot: u8, cancelable: bool| crate::script::AuraState { + spell_id, + slot, + cancelable, + helpful: true, + count: 1, + ..Default::default() + }; + s.set_auras( + "player", + Some(vec![aura(100, 3, true), aura(200, 7, false)]), + ); + + s.run("CancelPlayerAuraSpellId(100)").unwrap(); + assert_eq!(s.take_cancel_aura_requests(), vec![100]); + + // Not cancelable, not carried, and nonsense ids are all silent no-ops. + s.run("CancelPlayerAuraSpellId(200)").unwrap(); + s.run("CancelPlayerAuraSpellId(999)").unwrap(); + s.run("CancelPlayerAuraSpellId(0)").unwrap(); + s.run("CancelPlayerAuraSpellId()").unwrap(); + assert!(s.take_cancel_aura_requests().is_empty()); + + // The slot is raw, not the Lua index: aura 100 sits at slot 3, index 1. + s.run("CancelPlayerAuraSlot(3)").unwrap(); + assert_eq!(s.take_cancel_aura_requests(), vec![100]); + s.run("CancelPlayerAuraSlot(1)").unwrap(); + s.run("CancelPlayerAuraSlot(7)").unwrap(); + assert!( + s.take_cancel_aura_requests().is_empty(), + "slot 1 holds nothing and slot 7's aura is not cancelable" + ); + + // `ignoreMissing` is accepted and changes nothing: there is nothing here to complain. + s.run("CancelPlayerAuraSpellId(100, 1)").unwrap(); + assert_eq!(s.take_cancel_aura_requests(), vec![100]); + } + + /// The `*NoQueue` pair takes a **spell id** where stock `CastSpell` takes a book slot — the + /// whole reason upstream has them — and reaches the same queue a bar press does, since there + /// is no spell queue here to bypass. `"mouseover"` routes to the mouseover queue. + #[test] + fn the_no_queue_casts_push_a_spell_id_onto_the_ordinary_queue() { + let mut s = UiScript::new().unwrap(); + s.set_spellbook(crate::script::SpellBookState { + tabs: Vec::new(), + slots: vec![crate::script::SpellSlotView { + spell_id: 133, + name: "Fireball".into(), + rank: Some("Rank 1".into()), + ..Default::default() + }], + }); + + s.run("CastSpellNoQueue(133, 1)").unwrap(); + assert_eq!(s.take_spell_casts(), vec![133]); + + // An id the book does not carry: `spell_ident` answers for ids directly, as upstream's + // "cast by spell ID" says, so this is a send and not a refusal. + s.run("CastSpellNoQueue(4242, 1)").unwrap(); + assert_eq!(s.take_spell_casts(), vec![4242]); + + s.run(r#"CastSpellByNameNoQueue("Fireball")"#).unwrap(); + assert_eq!(s.take_spell_casts(), vec![133]); + + // The unit is third for the id form and second for the name form. + s.run(r#"CastSpellNoQueue(133, 1, "mouseover")"#).unwrap(); + assert!(s.take_spell_casts().is_empty()); + assert_eq!(s.take_mouseover_spell_casts(), vec![133]); + s.run(r#"CastSpellByNameNoQueue("Fireball", "mouseover")"#) + .unwrap(); + assert!(s.take_spell_casts().is_empty()); + assert_eq!(s.take_mouseover_spell_casts(), vec![133]); + + // A name nothing answers for, and no argument at all, are silent. + s.run(r#"CastSpellByNameNoQueue("Nothing At All")"#) + .unwrap(); + s.run("CastSpellNoQueue()").unwrap(); + assert!(s.take_spell_casts().is_empty()); + } + + /// The bag group — `FindPlayerItemSlot`, `GetBagItem`, `GetItemIdCooldown` — shares one + /// lookup, so the three cannot disagree about *which* of two identical stacks they mean: the + /// first, reading bags and slots ascending, as the reference's own auto-store search does. + #[test] + fn the_bag_group_finds_the_first_stack_and_agrees_about_which() { + let mut s = UiScript::new().unwrap(); + assert_eq!( + s.eval::<(Option, Option)>("return FindPlayerItemSlot(1234)") + .unwrap(), + (None, None), + "no containers fed yet" + ); + + // Two stacks of 1234: backpack slot 5 and bag 2 slot 1. The backpack one must win. + let slot = |item_id: u32, count: u32| crate::script::ContainerSlot { + item_id, + count, + ..Default::default() + }; + for (bag, slots) in [ + (0i64, vec![(5u32, slot(1234, 3)), (9, slot(555, 1))]), + (1, vec![]), + (2, vec![(1, slot(1234, 20))]), + (3, vec![]), + (4, vec![]), + ] { + s.set_container( + bag, + Some(crate::script::ContainerState { + name: None, + num_slots: 16, + slots: slots.into_iter().collect(), + }), + ); + } + assert_eq!( + s.eval::<(Option, Option)>("return FindPlayerItemSlot(1234)") + .unwrap(), + (Some(0), Some(5)) + ); + assert_eq!( + s.eval::<(Option, Option)>("return FindPlayerItemSlot(555)") + .unwrap(), + (Some(0), Some(9)) + ); + assert_eq!( + s.eval::<(Option, Option)>("return FindPlayerItemSlot(4242)") + .unwrap(), + (None, None), + "not carried" + ); + + // `GetBagItem` reports the slot it was asked about, not the found one. + assert_eq!( + s.eval::(r#"return GetBagItem(2, 1).count"#).unwrap(), + 20 + ); + assert_eq!( + s.eval::(r#"return GetBagItem(0, 5).itemId"#).unwrap(), + 1234 + ); + assert!(s + .eval::>("return GetBagItem(0, 7)") + .unwrap() + .is_none_or(|v| v.is_nil())); + + // A cold item answers the stock triple, not nil: the consumer does arithmetic on these. + assert_eq!( + s.eval::<(f64, f64, i64)>("return GetItemIdCooldown(1234)") + .unwrap(), + (0.0, 0.0, 1) + ); + assert_eq!( + s.eval::<(f64, f64, i64)>("return GetItemIdCooldown(4242)") + .unwrap(), + (0.0, 0.0, 1), + "not carried reads cold, as an absent slot does" + ); + } + /// `GetItemIconTexture` takes the two hops an item icon needs — the cache row's display id, /// then `ItemDisplayInfo` — and tells a data gap apart from an unknown item: a known item /// whose display row is missing answers nil, an item the cache has never seen raises, as the diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 26c0b0a7..d0e02ff9 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -306,6 +306,23 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> { // neither: its only icon path for an item is `GetContainerItemInfo` on a bag slot you // already hold, and it has no way at all to ask about an arbitrary item id or to read the // two trinket slots as a pair. Both are in `SuperCleveRoidMacros`'s `HasFeature` table. + // nampower v2's bag group (same file), all three sharing one lookup so they cannot + // disagree about which of two identical stacks they mean. 1.12 can ask about a bag slot + // it names (`GetContainerItemInfo`) but has no search by item id or name, and no + // cooldown read that is not already slot-addressed. + // nampower v2's outbound group (same file). 1.12 cancels an aura only by its 1-based + // buff *index* (`CancelPlayerBuff`), casts only by book slot (`CastSpell`), and uses an + // item only by a bag slot it already names — so each of these is a different key into a + // send that exists, not a new send. The `*NoQueue` pair is the plain cast here: there is + // no spell queue to bypass (`docs/ADDON_COMPAT.md`). + "CancelPlayerAuraSlot", + "CancelPlayerAuraSpellId", + "CastSpellByNameNoQueue", + "CastSpellNoQueue", + "UseItemIdOrName", + "FindPlayerItemSlot", + "GetBagItem", + "GetItemIdCooldown", "GetItemIconTexture", "GetTrinkets", "PlayerIsMoving",