From 02265cc22453849178c62b080de16c5b350c521c Mon Sep 17 00:00:00 2001 From: grom Date: Sat, 17 Oct 2026 18:57:12 -0600 Subject: [PATCH] compat: nampower's equipment and item-cache getters, for the character panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "then we gotta fix bettercharacterpanel, that one is driving me nuts." BetterCharacterPanel is built on five nampower getters and had none of them. `GetEquippedItem` was 8 raises in one session of his log; the other four failed inside the addon's own `pcall`s, so its item-level column, quality colours, missing-enchant warning and two-hand test were all dead at once. DoiteAuras reads the same getter in six places. The spec is the installed `nampower.dll` itself rather than inference, which is what unblocked this — the earlier note here said to find the upstream spec first, and the DLL *is* the spec: - its usage strings give the signatures, - "Slot must be a number between 1 and 19" gives the band, so the slots are 1.12's own `GetInventoryItemLink` numbering and `NampowerAPI.lua`'s `slot - 1` in `IsItemInSlot` is that shim's bug, not the contract, - its string pool gives the key names in order, and - "Item not found in DBCache" is a raise, which is exactly why the addon `pcall`s these three and calls `GetEquippedItem` bare. `itemLevel` is the one field 1.12 cannot reach at all: `GetItemInfo`'s fourth value is the *required* level, and later clients are the ones that insert `itemLevel` there — so the panel's item-level column had no possible source before this. The cache row now carries it, with `buyPrice` and `displayInfoID` beside it, and an equipped item carries the raw descriptor numbers the tooltip's resolved enchant lines could not answer (an id whose `SpellItemEnchantment` row hides its name is still an id). Fields left absent rather than guessed are listed in code and in `docs/ADDON_COMPAT.md`; nothing installed reads any of them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- crates/benilla-app/src/ui_char.rs | 15 +- crates/benilla-app/src/ui_inspect.rs | 12 + crates/benilla-app/src/ui_items/feed.rs | 3 + crates/benilla-app/src/ui_script/bag_tests.rs | 1 + .../src/ui_script/character_tests.rs | 1 + .../src/ui_script/tooltip_compare_tests.rs | 2 + crates/benilla-ui/src/script/char_stats.rs | 26 ++ .../benilla-ui/src/script/compat/nampower.rs | 389 +++++++++++++++++- crates/benilla-ui/src/script/cursor/doll.rs | 4 + crates/benilla-ui/src/script/item_stats.rs | 9 + crates/benilla-ui/src/script/mod.rs | 4 +- .../src/script/tests/reference_surface.rs | 13 + docs/ADDON_COMPAT.md | 1 + 13 files changed, 476 insertions(+), 4 deletions(-) diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index 725bd814..719bc9de 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -621,6 +621,17 @@ fn slot_view( creator, enchants, duration_ms, + // The raw descriptor numbers, beside the resolved lines above: nampower's equipment + // getters report the ids whether or not `SpellItemEnchantment` names them. + raw: benilla_ui::script::ItemRawFields { + enchant_ids: std::array::from_fn(|s| { + obj.item_enchant(u8::try_from(s).unwrap_or(0)).unwrap_or(0) + }), + temp_enchant_charges: obj.item_enchant_charges(1), + temp_enchant_remaining_ms: enchant_ms[1], + random_properties_id: obj.item_random_properties_id(), + spell_charges: obj.item_spell_charges(0).unwrap_or(0), + }, }) } @@ -708,9 +719,11 @@ fn inventory_slots( equip_slots: Vec::new(), bar_placeable: false, creator: None, - // No ammo instance streams, only its template id: no enchants, no countdown. + // No ammo instance streams, only its template id: no enchants, no countdown, and + // none of the raw descriptor numbers either. enchants: Vec::new(), duration_ms: None, + raw: benilla_ui::script::ItemRawFields::default(), }); } // Equipment 1..=19, then `Bag0Slot`..`Bag3Slot` (20..=23 in `PaperDollItemFrame.dbc`), the diff --git a/crates/benilla-app/src/ui_inspect.rs b/crates/benilla-app/src/ui_inspect.rs index 5a2c0fb4..6bec3d0f 100644 --- a/crates/benilla-app/src/ui_inspect.rs +++ b/crates/benilla-app/src/ui_inspect.rs @@ -100,6 +100,18 @@ fn inspect_slot_view( }), rolls.enchants, ), + // The same seven slots raw, for nampower's `GetEquippedItem` on an inspected unit. No + // charges and no countdown: those live on an item object this unit never sends us. + raw: benilla_ui::script::ItemRawFields { + enchant_ids: std::array::from_fn(|j| { + store + .0 + .player_visible_item_enchant(slot0, u8::try_from(j).unwrap_or(0)) + .unwrap_or(0) as i32 + }), + random_properties_id: u32::from(roll), + ..Default::default() + }, ..Default::default() }) } diff --git a/crates/benilla-app/src/ui_items/feed.rs b/crates/benilla-app/src/ui_items/feed.rs index 4a0cc7aa..6994a6e7 100644 --- a/crates/benilla-app/src/ui_items/feed.rs +++ b/crates/benilla-app/src/ui_items/feed.rs @@ -138,6 +138,9 @@ fn template_view( class: t.class, subclass: t.subclass, inventory_type: t.inventory_type, + item_level: t.item_level, + buy_price: t.buy_price, + display_info_id: t.display_info_id, proficiency_alt: sub_classes.and_then(|c| c.proficiency_alt(t.class, t.subclass)), hide_subclass: sub_classes.is_some_and(|c| c.hides_name(t.class, t.subclass)), // `GetItemInfo`'s type pair: the subclass is `name()`, verbose name first (`0x48e311`), diff --git a/crates/benilla-app/src/ui_script/bag_tests.rs b/crates/benilla-app/src/ui_script/bag_tests.rs index db83740a..b3544de5 100644 --- a/crates/benilla-app/src/ui_script/bag_tests.rs +++ b/crates/benilla-app/src/ui_script/bag_tests.rs @@ -1378,6 +1378,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() { equip_slots: vec![20], creator: None, enchants: Vec::new(), + raw: Default::default(), }); s.set_inventory_slots(inv); hover(&mut s, "CharacterBag0Slot"); diff --git a/crates/benilla-app/src/ui_script/character_tests.rs b/crates/benilla-app/src/ui_script/character_tests.rs index c66b96a6..7faabecf 100644 --- a/crates/benilla-app/src/ui_script/character_tests.rs +++ b/crates/benilla-app/src/ui_script/character_tests.rs @@ -69,6 +69,7 @@ fn inventory_with_head_item() -> InventorySlots { equip_slots: vec![1], creator: None, enchants: Vec::new(), + raw: Default::default(), }); slots } diff --git a/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs b/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs index a3bd7dbe..8389eff2 100644 --- a/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs +++ b/crates/benilla-app/src/ui_script/tooltip_compare_tests.rs @@ -90,6 +90,7 @@ fn seed_items(s: &mut UiScript) { equip_slots: vec![1], creator: None, enchants: Vec::new(), + raw: Default::default(), }); s.set_inventory_slots(inv); s.set_item_template( @@ -208,6 +209,7 @@ fn doll_hover_renders_the_live_instance() { equip_slots: vec![1], creator: None, enchants: Vec::new(), + raw: Default::default(), }); s.set_inventory_slots(inv); s.set_item_template( diff --git a/crates/benilla-ui/src/script/char_stats.rs b/crates/benilla-ui/src/script/char_stats.rs index 4396526e..3c655392 100644 --- a/crates/benilla-ui/src/script/char_stats.rs +++ b/crates/benilla-ui/src/script/char_stats.rs @@ -213,6 +213,32 @@ pub struct InvSlotView { pub enchants: Vec, /// The instance's remaining lifetime in ms; `None` without a timer. pub duration_ms: Option, + /// The raw descriptor numbers nampower's equipment getters report ([`ItemRawFields`]). + pub raw: ItemRawFields, +} + +/// The item-descriptor numbers nampower's `GetEquippedItem`/`GetEquippedItems` report, kept apart +/// from the display fields above because they are **raw**: `enchants` is the tooltip's resolved and +/// filtered view (a `SpellItemEnchantment` row with no name, or one flagged `0x2` to hide it, never +/// reaches that list), where an addon asking "is this slot enchanted" wants the id either way. +/// +/// Fields `nampower.dll` reports that are not here: `duration`, which is the descriptor's +/// `ITEM_FIELD_DURATION` in seconds, where this struct's sibling [`InvSlotView::duration_ms`] is +/// the display countdown — not the same number, and nothing installed reads it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ItemRawFields { + /// `ITEM_FIELD_ENCHANTMENT_*_1` by slot: 0 permanent, 1 temporary, 2..=6 the random suffix. + /// Negative ids are kept signed, as the descriptor carries them. + pub enchant_ids: [i32; 7], + /// The temporary slot's charges, `ITEM_FIELD_ENCHANTMENT_2_3`. + pub temp_enchant_charges: u32, + /// Ms left on the temporary enchant, from `SMSG_ITEM_ENCHANT_TIME_UPDATE` — the live countdown + /// nampower names `tempEnchantmentTimeLeftMs`, not the descriptor's duration word. + pub temp_enchant_remaining_ms: Option, + /// `ITEM_FIELD_RANDOM_PROPERTIES_ID`. + pub random_properties_id: u32, + /// `ITEM_FIELD_SPELL_CHARGES` slot 0, the charges a **Use:** item has left. + pub spell_charges: i32, } /// The doll snapshot: 0 ammo, 1..=19 equipment, 20..=23 the equipped bags, as diff --git a/crates/benilla-ui/src/script/compat/nampower.rs b/crates/benilla-ui/src/script/compat/nampower.rs index c72c0966..02f90467 100644 --- a/crates/benilla-ui/src/script/compat/nampower.rs +++ b/crates/benilla-ui/src/script/compat/nampower.rs @@ -8,7 +8,7 @@ //! ClassicAPI this bundle uses — is its live continuation and the source of every documented //! default in the CVar block. -use mlua::Lua; +use mlua::{Lua, Value}; /// What `GetNampowerVersion()` reports, as `(major, minor, patch)`. /// @@ -98,6 +98,150 @@ fn spell_ident(model: &crate::script::Model, v: Option<&mlua::Value>) -> Option< } } +/// The top of the equipment band nampower reads, its own `1 and 19`: 1 Head … 19 Tabard, which is +/// also 1.12's `GetInventoryItemLink` numbering and this model's `inv_slot` index. +const EQUIPPED_SLOT_MAX: usize = 19; + +/// The slot argument, raising nampower's own two messages: one for a non-number, one for a number +/// outside the band. A raise, not a nil, because that is what the DLL does — and a caller passing +/// 0 (the `slot - 1` bug in `NampowerAPI.lua`'s `IsItemInSlot`) should see it. +fn equipped_slot_arg(slot: &Option) -> mlua::Result { + let n = match slot { + Some(Value::Integer(i)) => *i as f64, + Some(Value::Number(n)) => *n, + Some(Value::String(s)) => s + .to_str() + .ok() + .and_then(|s| s.trim().parse::().ok()) + .ok_or_else(|| mlua::Error::runtime("Slot must be a number between 1 and 19"))?, + _ => { + return Err(mlua::Error::runtime( + "Slot must be a number between 1 and 19", + )) + } + }; + let slot = n as i64; + if n.fract() != 0.0 || !(1..=EQUIPPED_SLOT_MAX as i64).contains(&slot) { + return Err(mlua::Error::runtime("Slot must be between 1 and 19")); + } + Ok(slot as usize) +} + +/// Which inventory the unit argument names. Only our own items and the open inspect record are +/// readable, which is nampower's own limit too ("Cannot inspect unit"); any token or GUID that +/// resolves to our own unit reads our own items, so `GetEquippedItem(UnitGUID("player"), 1)` and +/// `GetEquippedItem("target", 1)` while self-targeted both answer. +fn inv_token(model: &crate::script::Model, unit: Option) -> String { + let token = unit.unwrap_or_else(|| "player".to_string()); + if token.eq_ignore_ascii_case("player") { + return token; + } + let guid_of = |t: &str| model.unit(t).map(|u| u.guid).filter(|&g| g != 0); + match (guid_of(&token), guid_of("player")) { + (Some(a), Some(b)) if a == b => "player".to_string(), + _ => token, + } +} + +/// One equipped item as nampower reports it, its key names verbatim from the DLL's string pool. +/// +/// `duration` is the one documented key left out: it is the descriptor's `ITEM_FIELD_DURATION` in +/// seconds, where what this model carries is the display countdown in ms +/// ([`crate::script::InvSlotView::duration_ms`]) — a different number under the same name, and +/// nothing installed reads it. +fn equipped_table(lua: &Lua, view: &crate::script::InvSlotView) -> mlua::Result { + let t = lua.create_table()?; + t.set("itemId", view.item_id)?; + t.set("permanentEnchantId", view.raw.enchant_ids[0])?; + t.set("tempEnchantId", view.raw.enchant_ids[1])?; + t.set("randomPropertiesId", view.raw.random_properties_id)?; + t.set("stackCount", view.count)?; + t.set("spellChargesRemaining", view.raw.spell_charges)?; + t.set("flags", view.flags)?; + let (cur, max) = view.durability.unwrap_or((0, 0)); + t.set("durability", cur)?; + t.set("maxDurability", max)?; + t.set( + "tempEnchantmentTimeLeftMs", + view.raw.temp_enchant_remaining_ms.unwrap_or(0), + )?; + t.set("tempEnchantmentCharges", view.raw.temp_enchant_charges)?; + Ok(t) +} + +/// The item cache row for an id, asking for it exactly as `GetItemInfo` does when it is a miss, so +/// the call that follows the query's answer succeeds. +/// +/// A miss **raises** `Item not found in DBCache`, the DLL's own message, rather than answering nil: +/// that string sits in its pool beside the `Usage:` lines, and the callers show it is what they +/// 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. +fn item_record(lua: &Lua, item_id: Option) -> mlua::Result { + let found = item_id.filter(|&id| id != 0).and_then(|id| { + let mut model = lua + .app_data_mut::() + .expect("model app_data"); + let found = model.item_templates.get(&id).cloned(); + if found.is_none() { + model.item_stat_asks.insert(id); + } + found + }); + found.ok_or_else(|| mlua::Error::runtime("Item not found in DBCache")) +} + +/// The cache row as nampower's `GetItemStats` table, its key names verbatim from the DLL's string +/// pool. +/// +/// Keys the DLL has that this client has no number for, left absent rather than guessed: +/// `ammoType`, `rangedModRange`, `languageID`, `pageMaterial`, `material`, `sheatheType`, `area`, +/// `duration`, `bagFamily`, `bonusStat`, `bonusAmount`, the `resistances` array (held here as six +/// scalars whose index base in nampower's table is untraced) and the `spell*` block (five slots in +/// the wire record, flattened to one name in the DLL's pool). Nothing installed reads any of them; +/// a miss answers nil, which is what a caller's `pcall`/`and` guard already handles. +fn item_stats_table(lua: &Lua, v: &crate::script::ItemTemplateView) -> mlua::Result { + let t = lua.create_table()?; + t.set("displayName", v.name.clone())?; + t.set("class", v.class)?; + t.set("subclass", v.subclass)?; + t.set("displayInfoID", v.display_info_id)?; + t.set("quality", v.quality)?; + t.set("flags", v.flags)?; + t.set("buyPrice", v.buy_price)?; + t.set("sellPrice", v.sell_price)?; + t.set("inventoryType", v.inventory_type)?; + t.set("allowableClass", v.allowable_class)?; + t.set("allowableRace", v.allowable_race)?; + t.set("itemLevel", v.item_level)?; + t.set("requiredLevel", v.required_level)?; + t.set("requiredSkill", v.required_skill)?; + t.set("requiredSkillRank", v.required_skill_rank)?; + t.set("requiredSpell", v.required_spell)?; + t.set("requiredHonorRank", v.required_honor_rank)?; + t.set("requiredCityRank", v.required_city_rank)?; + t.set("requiredRep", v.required_rep_faction)?; + t.set("requiredRepRank", v.required_rep_rank)?; + t.set("maxCount", v.max_count)?; + t.set("stackable", v.stackable)?; + t.set("containerSlots", v.container_slots)?; + t.set("delay", v.delay_ms)?; + t.set("bonding", v.bonding)?; + t.set("pageText", v.page_text)?; + t.set("startQuestID", v.start_quest)?; + t.set("lockID", v.lock_id)?; + t.set("randomProperty", v.random_property)?; + t.set("block", v.block)?; + t.set("itemSet", v.item_set)?; + t.set("maxDurability", v.max_durability)?; + // The first damage pair, the one a weapon's speed and DPS are read with. + let (min, max, kind) = v.damages.first().copied().unwrap_or((0.0, 0.0, 0)); + t.set("minDamage", min)?; + t.set("maxDamage", max)?; + t.set("damageType", kind)?; + Ok(t) +} + pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { // GetNampowerVersion() → major, minor, patch. Three numbers, which is what // `NampowerSettings`' `HasMinimumVersion` destructures and compares component-wise. @@ -500,6 +644,81 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; + // ── the item-cache and equipment getters (v2.14+/v2.18+) ──────────────────────────────── + // + // Field names, slot numbering and messages here are read off the installed `nampower.dll` + // itself, not inferred: its own usage strings say `GetEquippedItem(unitStr, slot)` and + // **"Slot must be a number between 1 and 19"**, so the slot band is 1.12's own + // `GetInventoryItemLink` numbering (1 Head … 19 Tabard) and `NampowerAPI.lua`'s `slot - 1` in + // `IsItemInSlot` is that shim's bug, not the contract. The record's keys are the DLL's string + // pool in order. "Item not found in DBCache" is its miss, which is this model's + // `item_templates` — the same cache `GetItemInfo` reads, with the same ask-on-miss. + lua.globals().set( + "GetEquippedItem", + lua.create_function(|lua, (unit, slot): (Option, Option)| { + let slot = equipped_slot_arg(&slot)?; + let model = lua + .app_data_ref::() + .expect("model app_data"); + let token = inv_token(&model, unit); + match model.inv_slot(&token, slot).filter(|v| v.item_id != 0) { + Some(view) => Ok(Value::Table(equipped_table(lua, &view)?)), + None => Ok(Value::Nil), + } + })?, + )?; + + // `GetEquippedItems([unitStr])` -> a table keyed by the same 1..19 slots, empty slots absent. + lua.globals().set( + "GetEquippedItems", + lua.create_function(|lua, unit: Option| { + let model = lua + .app_data_ref::() + .expect("model app_data"); + let token = inv_token(&model, unit); + let out = lua.create_table()?; + for slot in 1..=EQUIPPED_SLOT_MAX { + if let Some(view) = model.inv_slot(&token, slot).filter(|v| v.item_id != 0) { + out.set(slot, equipped_table(lua, &view)?)?; + } + } + Ok(out) + })?, + )?; + + // `GetItemLevel(itemId)` (v2.14+) -> the cache row's `ItemLevel`. No 1.12 binding answers it: + // `GetItemInfo`'s fourth value is the *required* level, and later clients are the ones that + // insert `itemLevel` there. BetterCharacterPanel's whole item-level column is this call. + lua.globals().set( + "GetItemLevel", + lua.create_function(|lua, item_id: Option| Ok(item_record(lua, item_id)?.item_level))?, + )?; + + // `GetItemStats(itemId, [copy])` -> the cache row as a table. `copy` is accepted and ignored: + // it asks nampower not to hand back its reused table, and every call here builds a fresh one. + lua.globals().set( + "GetItemStats", + lua.create_function(|lua, (item_id, _copy): (Option, Option)| { + item_stats_table(lua, &item_record(lua, item_id)?) + })?, + )?; + + // `GetItemStatsField(itemId, fieldName, [copy])` -> one field of that table: nil for a name + // this client has no number for (the omissions are listed on [`item_stats_table`]), and a + // raise for an item the cache has never seen, as [`item_record`] explains. + lua.globals().set( + "GetItemStatsField", + lua.create_function( + |lua, (item_id, field, _copy): (Option, Option, Option)| { + let record = item_record(lua, item_id)?; + let Some(field) = field else { + return Ok(Value::Nil); + }; + item_stats_table(lua, &record)?.get::(field) + }, + )?, + )?; + // `GetSpellIconTexture(spellIconID)` -> the icon's MPQ path. The pair's other half: a caller // reads `GetSpellRecField(id, "spellIconID")` and resolves it here, which is how an addon // draws an icon for a spell the player does not know (and so has no spellbook slot to read a @@ -535,6 +754,174 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> { mod tests { use crate::script::UiScript; + /// BetterCharacterPanel's four reads, in its own shapes + /// (`BetterCharacterPanel-lib.lua:93`, `:177`, `:193`, `:252`): the enchant check, the item + /// level, the quality colour and the two-hand test. All four were dead — `GetEquippedItem` was + /// nil (8 raises in one session of his log) and the other two silently failed inside the + /// addon's own `pcall`s, so the panel showed no level, no colour and no missing-enchant + /// warning. + #[test] + fn the_character_panels_four_item_reads_all_answer() { + let mut s = UiScript::new().unwrap(); + let mut inv: crate::script::InventorySlots = Default::default(); + // Slot 16 is MainHand in the band nampower names 1..19, which is also `BS.MainHand`. + inv[16] = Some(crate::script::InvSlotView { + item_id: 12784, + name: Some("Arcanite Reaper".into()), + quality: 4, + durability: Some((105, 120)), + raw: crate::script::ItemRawFields { + // Crusader, in the permanent slot. + enchant_ids: [1900, 0, 0, 0, 0, 0, 0], + ..Default::default() + }, + ..Default::default() + }); + // Slot 1 is Head: equipped, and deliberately unenchanted. + inv[1] = Some(crate::script::InvSlotView { + item_id: 16966, + name: Some("Lionheart Helm".into()), + quality: 4, + ..Default::default() + }); + s.set_inventory_slots(inv); + s.set_item_template( + 12784, + crate::script::ItemTemplateView { + name: "Arcanite Reaper".into(), + quality: 4, + item_level: 63, + inventory_type: 17, // INVTYPE_2HWEAPON, the addon's own test + delay_ms: 3800, + ..Default::default() + }, + ); + + // 1. the enchant check: the id, whatever `SpellItemEnchantment` calls it. + assert_eq!( + s.eval::(r#"return GetEquippedItem("player", 16).permanentEnchantId"#) + .unwrap(), + 1900 + ); + assert_eq!( + s.eval::(r#"return GetEquippedItem("player", 1).permanentEnchantId"#) + .unwrap(), + 0, + "an unenchanted slot answers 0, which is what the addon's `~= 0` tests" + ); + // 2. the item level, which no 1.12 binding carries. + assert_eq!( + s.eval::( + r#"local i = GetEquippedItem("player", 16) return GetItemLevel(i.itemId)"# + ) + .unwrap(), + 63 + ); + // 3. the quality colour and 4. the two-hand test, both through `GetItemStatsField`. + assert_eq!( + s.eval::(r#"return GetItemStatsField(12784, "quality")"#) + .unwrap(), + 4 + ); + assert!(s + .eval::(r#"return GetItemStatsField(12784, "inventoryType") == 17"#) + .unwrap()); + // The durability pair the panel's own bar reads, and a field this client has no number + // for: absent, not invented. + assert_eq!( + s.eval::( + r#"local i = GetEquippedItem("player", 16) + return i.durability .. "/" .. i.maxDurability"# + ) + .unwrap(), + "105/120" + ); + assert!(s + .eval::(r#"return GetEquippedItem("player", 16).duration == nil"#) + .unwrap()); + // An empty slot is nil, not an empty table: every caller tests `if not itemInfo then`. + assert!(s + .eval::(r#"return GetEquippedItem("player", 5) == nil"#) + .unwrap()); + // And the plural, keyed by the same slots. + assert_eq!( + s.eval::( + r#"local n = 0 + for _ in pairs(GetEquippedItems("player")) do n = n + 1 end + return n"# + ) + .unwrap(), + 2 + ); + assert!(s.take_errors().is_empty()); + } + + /// An id the cache has never seen raises the DLL's own `Item not found in DBCache`, which is + /// what BetterCharacterPanel's `pcall` around these three getters is for — and the ask the + /// miss records is what makes the next call answer. + #[test] + fn an_uncached_item_raises_the_dlls_own_miss_and_asks_for_the_row() { + let mut s = UiScript::new().unwrap(); + for call in [ + "GetItemLevel(99999)", + "GetItemStats(99999)", + r#"GetItemStatsField(99999, "quality")"#, + ] { + let err = s.run(&format!("return {call}")).unwrap_err().to_string(); + assert!(err.contains("Item not found in DBCache"), "{call}: {err}"); + } + // The addon's own guard shape: a raise reaches it as `false`, never as a broken panel. + assert!(s + .eval::("local ok = pcall(GetItemLevel, 99999) return ok == false") + .unwrap()); + // Each miss asked for the row, so the answer makes the next call work. + assert!( + s.take_item_stat_asks().contains(&99999), + "a miss must ask the server for the template" + ); + s.set_item_template( + 99999, + crate::script::ItemTemplateView { + item_level: 42, + ..Default::default() + }, + ); + assert_eq!(s.eval::("return GetItemLevel(99999)").unwrap(), 42); + } + + /// The DLL's own band and its own two messages. `NampowerAPI.lua`'s `IsItemInSlot` passes + /// `slot - 1`, so slot 1 arrives as 0 — that raise is the shim's bug surfacing, and hiding it + /// behind a nil would make the off-by-one invisible. + #[test] + fn the_slot_band_is_the_dlls_own_one_to_nineteen() { + let s = UiScript::new().unwrap(); + for (call, want) in [ + ( + r#"GetEquippedItem("player", 0)"#, + "Slot must be between 1 and 19", + ), + ( + r#"GetEquippedItem("player", 20)"#, + "Slot must be between 1 and 19", + ), + ( + r#"GetEquippedItem("player", "head")"#, + "Slot must be a number between 1 and 19", + ), + ( + r#"GetEquippedItem("player")"#, + "Slot must be a number between 1 and 19", + ), + ] { + let err = s.run(&format!("return {call}")).unwrap_err().to_string(); + assert!(err.contains(want), "{call}: wanted {want:?}, got {err}"); + } + // In the band, with nothing equipped: nil, no raise. + assert!(s + .eval::(r#"return GetEquippedItem("player", 19) == nil"#) + .unwrap()); + } + /// The length comes off the same reader `SPELL_START_*`'s `arg7` uses, and keeps upstream's /// three-way answer: nil for an id that is not a spell, 0 for a spell this client cannot /// state a length for, the milliseconds otherwise. Collapsing the first two would make every diff --git a/crates/benilla-ui/src/script/cursor/doll.rs b/crates/benilla-ui/src/script/cursor/doll.rs index 6005cd27..b91038ce 100644 --- a/crates/benilla-ui/src/script/cursor/doll.rs +++ b/crates/benilla-ui/src/script/cursor/doll.rs @@ -263,6 +263,7 @@ mod tests { equip_slots: vec![1], creator: None, enchants: Vec::new(), + raw: Default::default(), }); slots[11] = Some(InvSlotView { duration_ms: None, @@ -282,6 +283,7 @@ mod tests { equip_slots: vec![11, 12], // either finger: the doll-to-doll swap creator: None, enchants: Vec::new(), + raw: Default::default(), }); slots[19] = Some(InvSlotView { duration_ms: None, @@ -301,6 +303,7 @@ mod tests { equip_slots: vec![19], creator: None, enchants: Vec::new(), + raw: Default::default(), }); slots } @@ -472,6 +475,7 @@ mod tests { equip_slots: vec![20, 21, 22, 23], creator: None, enchants: Vec::new(), + raw: Default::default(), }); s.set_inventory_slots(slots); diff --git a/crates/benilla-ui/src/script/item_stats.rs b/crates/benilla-ui/src/script/item_stats.rs index 6b37bb8a..89f850fa 100644 --- a/crates/benilla-ui/src/script/item_stats.rs +++ b/crates/benilla-ui/src/script/item_stats.rs @@ -20,6 +20,15 @@ pub struct ItemTemplateView { pub class: u32, pub subclass: u32, pub inventory_type: u32, + /// `ItemLevel`, which no 1.12 binding exposes — `GetItemInfo`'s fourth value is the *required* + /// level (`0x48e070` pushes nine values and later clients insert `itemLevel` at 4). Carried for + /// nampower's `GetItemLevel`/`GetItemStats`, which read the same cache row. + pub item_level: u32, + /// `BuyPrice` in copper, the merchant's side of the pair whose `sell_price` half the tooltip + /// already used; nampower's record reports both. + pub buy_price: u32, + /// `DisplayInfoID`, the `ItemDisplayInfo.dbc` row behind `icon` above. + pub display_info_id: u32, /// `GetItemInfo`'s `itemType`, the `ItemClass.dbc` name (`0x48e070`); `None` answers `""`. pub item_type: Option, /// `GetItemInfo`'s `itemSubType`, "One-Handed Swords": `ItemSubClass.dbc`'s VerboseName diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index cbcadf8f..00b4daa4 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -177,8 +177,8 @@ pub use bind_confirm::PendingEquipAnswer; pub use camera_view::{CameraViewRequest, CAMERA_VIEW_COUNT}; pub use channel::{ChannelCommand, ZoneChannelRow}; pub use char_stats::{ - weapon_subclass_skill, BankBagSlots, InvSlotView, InventorySlots, UnitCombatStats, - BANK_BAG_SLOT_COUNT, INVENTORY_SLOT_COUNT, SKILL_DEFENSE, SKILL_UNARMED, + weapon_subclass_skill, BankBagSlots, InvSlotView, InventorySlots, ItemRawFields, + UnitCombatStats, BANK_BAG_SLOT_COUNT, INVENTORY_SLOT_COUNT, SKILL_DEFENSE, SKILL_UNARMED, }; pub use chat_misc::EmoteRequest; pub use chat_send::ChatSend; diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 01a8b15d..e2006221 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -144,6 +144,19 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> { // nampower's item->Use:-spell lookup. 1.12 can only reach it by building a tooltip and // reading its lines back, which is what every addon without this did. "GetItemSpell", + // nampower's equipment and item-cache getters (v2.14+/v2.18+), the five + // BetterCharacterPanel is built on: `GetEquippedItem` was 8 raises in one session of his + // log, and the item-level column, the quality colours and the missing-enchant warning were + // all dead behind the addon's own `pcall`s. 1.12 reaches none of this — its item cache has + // no binding at all, and `GetItemInfo`'s fourth value is the *required* level, not the item + // level (later clients are the ones that insert `itemLevel` there). Field names, the 1..19 + // slot band and the `Item not found in DBCache` miss are read off the installed + // `nampower.dll`'s own string pool, not inferred. + "GetEquippedItem", + "GetEquippedItems", + "GetItemLevel", + "GetItemStats", + "GetItemStatsField", // nampower's `SpellIcon.dbc` reader, the resolver half of `GetSpellRecField(id, // "spellIconID")`. 1.12 reaches an icon only through a spellbook slot, so a spell the // player does not know has no texture an addon can draw. diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 9fb4d18c..6c8b7386 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -417,3 +417,4 @@ with the load checked after each. | The object sparkle was armed but buried inside the object | **fixed** — the placement is ours | *"ore vein is still not sparkling"*, after the switch fix. Reproduced offline in the `fxview` GameObject lane (`WOW_CAPTURE=fxview WOW_FX_GO=310 WOW_FX_GO_TYPE=3`, a copper mining node): the gate fires, `attach_spell_fx` spawns the instance (`kit spawn … Particles\LootFX.mdl`), and `WOW_PARTICLE_CENSUS` lists all four of `LootFX`'s emitters at the node's own position, live and ungated — while the captured frame is **byte-identical** to one taken with the sparkle gated off. The attach cascade's last rung is the host's origin, which for a unit is right (`0x13` is attachment 19, Base, the ground point under its feet, and this is a ground twinkle); a 1.12 **GameObject** model authors no attachment points at all (`copper_Miningnode_01.m2`: 0), so the effect sat at the node's pivot, inside the rock — that model's own vertices run y ∈ [-3.83, +2.73]. A host with no attachment points now seats the effect at the top centre of its own box, and the capture differs; the same sparkle on a peacebloom, whose model is small, already showed at the pivot. The placement is ours and no more: SuperWoW publishes no source and the reference has no object arm to copy. It is also **subtle by design** — `SpellVisualEffectName` row 14 scales 1.0 and `Particles\LootFX.m2` is four emitters of 0.06–0.14 yd particles with ~0.9 yd of reach — so it reads as a few twinkles over the node rather than a beacon | | A frame left in saved variables came back as a bag of its fields | **fixed** | `attempt to call method 'AddMessage' (a nil value)` at `Decursive.lua:1393`, every login. Decursive keeps its output window in its own saved table (`Dcr_OutputWindow = DEFAULT_CHAT_FRAME`) and guards the reload with `if Dcr_Saved.Dcr_OutputWindow == nil or not ...` (`:1245`) — which a non-nil **data** table passes. Our writer walked the frame like any other table and wrote ChatFrame1's own fields (`buttonSide`, `channelList`, `editBox`, …), doubling the file (2217 bytes against his real client's 1108) and losing every method. The reference tests the value's `T[0]` identity first — `lua_rawgeti(v, 0)` then `lua_touserdata` at `0x704613`-`0x704628`, the lightuserdata `0x701bd0` wrote — and never walks a widget: a **named** one writes as its bare global name, a Lua expression, so the reloaded chunk's own lookup hands the live object back, and an anonymous one as `nil --[[ unnamed object ]]` (`0x872e0c`). His own real-client file is the proof: `["Dcr_OutputWindow"] = ChatFrame1,`. Deviation: a name that is no Lua identifier writes as an anonymous object, where the reference writes it raw (`%s`) and can leave a file that will not parse. Tests: `a_saved_frame_comes_back_as_the_frame_not_a_copy_of_its_fields`, `an_anonymous_frame_saves_as_the_references_unnamed_object` | | `SetScript("OnMessageScrollChanged")` raised, aborting the builder around it | **fixed** | WIM sets it on every window it builds — the history frame at load and then one per correspondent (6 whisper windows in one session of his log, each `WIM_msgFrameScrollingMessageFrame`) — so refusing the name by raise killed the rest of that builder function every time, not just the handler. It was on the deliberate "real 1.12 kinds nothing fires, so raise rather than silently accept" list (`object/events_regions.rs`), which is the right rule and the wrong side of it here: the slot now **fires**, from the ScrollingMessageFrame's own scroll verbs when a scroll actually moves the view (`messageframe::scrolling::scroll`), which is what a chat addon hangs its scroll-down arrow on. A scroll refused at either end moves nothing and says nothing; whether the reference also fires one there is untraced. Test: `a_scroll_that_moves_the_view_fires_the_frames_scroll_script`, and the raise list's own test keeps its rule (`the_unfired_script_kinds_still_raise_rather_than_silently_accepting`) | +| nampower's equipment and item-cache getters (`GetEquippedItem`, `GetEquippedItems`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`) | **built** | *"then we gotta fix bettercharacterpanel, that one is driving me nuts"*. BetterCharacterPanel is built on all five: `GetEquippedItem` was 8 raises in one session (`BetterCharacterPanel-lib.lua:93`, `:193`, `-paperdoll.lua:179`) and the other four failed inside the addon's own `pcall`s, so the panel's item-level column, its quality colours, its missing-enchant warning and its two-hand test were all dead at once. DoiteAuras reads the same getter in six places. **The spec is the installed `nampower.dll`'s own string pool, not inference**: its usage strings give the signatures (`GetEquippedItem(unitStr, slot) or GetEquippedItem(guid, slot)`), its validation message **"Slot must be a number between 1 and 19"** gives the band — 1.12's own `GetInventoryItemLink` numbering, so `NampowerAPI.lua`'s `slot - 1` in `IsItemInSlot` is that shim's bug and not the contract — and the key names are its pooled strings in order: `itemId`, `permanentEnchantId`, `tempEnchantId`, `randomPropertiesId`, `stackCount`, `duration`, `spellChargesRemaining`, `flags`, `durability`, `maxDurability`, `tempEnchantmentTimeLeftMs`, `tempEnchantmentCharges` for an item, and `class`/`subclass`/`displayInfoID`/`quality`/`buyPrice`/`sellPrice`/`itemLevel`/… for a cache row. Its miss, **"Item not found in DBCache"**, is a raise, which is why the addon `pcall`s exactly these three and calls `GetEquippedItem` bare — ours raises the same string and records the template ask, so the call after the query's answer succeeds. `itemLevel` is the field 1.12 cannot reach at all: `GetItemInfo`'s fourth value is the *required* level (`0x48e070` pushes nine values; later clients are the ones that insert `itemLevel` at 4), so the item-level column had no possible source before this. Left absent rather than guessed: `duration` (the descriptor's seconds, where this client holds a display countdown in ms), the `resistances` array (six scalars here, index base in nampower's table untraced), the five-slot `spell*` block, and `ammoType`/`rangedModRange`/`languageID`/`pageMaterial`/`material`/`sheatheType`/`area`/`bagFamily`/`bonusStat`/`bonusAmount` — nothing installed reads any of them. An inspected unit answers from `PLAYER_VISIBLE_ITEM_*`, which carries the item and its two enchant slots and nothing else; nampower's own limit there is the same ("Cannot inspect unit"). Tests: `the_character_panels_four_item_reads_all_answer`, `an_uncached_item_raises_the_dlls_own_miss_and_asks_for_the_row`, `the_slot_band_is_the_dlls_own_one_to_nineteen` |