diff --git a/crates/benilla-ui/src/script/compat/classic_api/merchant_frame.rs b/crates/benilla-ui/src/script/compat/classic_api/merchant_frame.rs
index 7238c24a..6fd8fc59 100644
--- a/crates/benilla-ui/src/script/compat/classic_api/merchant_frame.rs
+++ b/crates/benilla-ui/src/script/compat/classic_api/merchant_frame.rs
@@ -135,6 +135,43 @@ pub(super) fn install(lua: &Lua) -> mlua::Result
{
.map(move |(slot, _)| (bag, *slot))
})
.collect();
+
+ // Every occupied slot this sweep could not judge, because the gate above is a
+ // **known** quality and `ContainerSlot::quality` is `None` until the item's template
+ // is in hand (`ui_items::feed`'s no-template arm: "show the slot occupied while the
+ // answer is in flight"). Such a slot is invisible to `GetNumJunkItems` as well, so a
+ // grey sitting in one is neither counted nor sold — and "not all grey items sell on
+ // the sell-junk button" is a defect grom reported from play.
+ //
+ // This says so instead of guessing which items they were. One line per distinct set,
+ // on the **click** rather than per frame, naming the item ids so the next session's
+ // log answers it: if the list is empty the cause is elsewhere (the slot → guid
+ // resolution in `ui_merchant`'s drain, or a server that refuses the sale), and if it
+ // is not, the fix is to wait for those templates before judging the sweep.
+ let unknown: Vec<(i64, u32, u32)> = CARRIED_BAGS
+ .iter()
+ .filter_map(|bag| model.containers.get(bag).map(|c| (*bag, c)))
+ .flat_map(|(bag, c)| {
+ c.slots
+ .iter()
+ .filter(|(_, s)| s.item_id != 0 && s.quality.is_none())
+ .map(move |(slot, s)| (bag, *slot, s.item_id))
+ })
+ .collect();
+ if !unknown.is_empty() {
+ let shown: Vec = unknown
+ .iter()
+ .take(12)
+ .map(|(bag, slot, id)| format!("({bag},{slot}) item {id}"))
+ .collect();
+ model.record_warning_once(format!(
+ "SellAllJunkItems: {} carried slot(s) have no item template yet, so their quality is unknown and this sweep could neither count nor sell them — a grey among them does not sell: {}{}",
+ unknown.len(),
+ shown.join(", "),
+ if unknown.len() > 12 { ", …" } else { "" }
+ ));
+ }
+
model.merchant_junk_sells.extend(junk);
Ok(())
})?,
@@ -183,6 +220,29 @@ pub(super) fn install_mail_globals(lua: &Lua) -> mlua::Result<()> {
// rows carry `item_id` and `item_name` but no link string (the app pushes links for bag, bank
// and cursor items, not inbox ones), so this builds the basic form from those two. Nil until
// the item's name has arrived, since a link with no name is not one.
+ // GetInboxItemID(index) → the item id behind the row, or nil. Beyond 1.12, like `GetInventoryItemID`,
+ // and the family pfUI's vendor-price line is keyed on: `modules/sellvalue.lua` hooks each
+ // `Set*Item` tooltip verb and takes the row's **id**, never its link, then asks
+ // `C_Item.GetItemSellPriceByID`. A missing getter answers nil and `AddVendorPrices` returns on
+ // its first line. The id is also the earlier fact: it rides the packet, where
+ // `GetInboxItemLink` needs the item's name to have arrived first.
+ g.set(
+ "GetInboxItemID",
+ lua.create_function(|lua, index: usize| {
+ let model = lua.app_data_ref::().expect("model app_data");
+ let id = model
+ .mail
+ .as_ref()
+ .and_then(|m| m.inbox.get(index.wrapping_sub(1)))
+ .map(|r| r.item_id)
+ .filter(|&id| id != 0);
+ match id {
+ Some(id) => Ok(Value::Integer(i64::from(id))),
+ None => Ok(Value::Nil),
+ }
+ })?,
+ )?;
+
g.set(
"GetInboxItemLink",
lua.create_function(|lua, (index, attachment): (usize, Option)| {
@@ -251,6 +311,58 @@ mod tests {
s
}
+ /// The sweep says what it could not judge.
+ ///
+ /// `SellAllJunkItems` and `GetNumJunkItems` both gate on a **known** poor quality, and a
+ /// carried slot's quality is `None` until its item template arrives. A grey sitting in such a
+ /// slot is neither counted nor sold, which is what "not all grey items sell on the sell-junk
+ /// button" looks like from the outside — so the sweep records the slots it skipped for that
+ /// reason rather than leaving the next session to guess.
+ #[test]
+ fn the_junk_sweep_names_the_slots_whose_template_has_not_arrived() {
+ let mut s = merchant();
+ let slot = |item_id: u32, quality: Option| ContainerSlot {
+ item_id,
+ quality,
+ ..Default::default()
+ };
+ let mut slots = HashMap::new();
+ slots.insert(1, slot(4306, Some(0))); // a known grey: sells
+ slots.insert(2, slot(1234, None)); // template in flight: invisible to the gate
+ slots.insert(3, slot(5678, None));
+ slots.insert(4, slot(858, Some(1))); // a known white: not junk, not reported
+ s.set_container(
+ 0,
+ Some(ContainerState {
+ name: None,
+ num_slots: 16,
+ slots,
+ }),
+ );
+
+ assert_eq!(
+ s.eval::("return C_MerchantFrame.GetNumJunkItems()")
+ .unwrap(),
+ 1,
+ "only the slot whose quality is known counts"
+ );
+ s.run("C_MerchantFrame.SellAllJunkItems()").unwrap();
+
+ let warnings = s.take_warnings().join("\n");
+ assert!(
+ warnings.contains("2 carried slot(s) have no item template yet"),
+ "the sweep must say how many it could not judge: {warnings}"
+ );
+ assert!(
+ warnings.contains("item 1234") && warnings.contains("item 5678"),
+ "…and name them, so the next log answers which items they were: {warnings}"
+ );
+ assert!(
+ !warnings.contains("item 858"),
+ "a slot with a known quality is not in doubt: {warnings}"
+ );
+ }
+
#[test]
fn merchant_item_info_is_a_named_table_over_the_stock_row() {
let s = merchant();
diff --git a/crates/benilla-ui/src/script/loot.rs b/crates/benilla-ui/src/script/loot.rs
index a08d503a..55527f2e 100644
--- a/crates/benilla-ui/src/script/loot.rs
+++ b/crates/benilla-ui/src/script/loot.rs
@@ -164,6 +164,31 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
+ // GetLootSlotItemID(slot) → the item id behind the row, or nil. Beyond 1.12, like `GetInventoryItemID`,
+ // and the family pfUI's vendor-price line is keyed on: `modules/sellvalue.lua` hooks each
+ // `Set*Item` tooltip verb and takes the row's **id**, never its link, then asks
+ // `C_Item.GetItemSellPriceByID`. A missing getter answers nil and `AddVendorPrices` returns on
+ // its first line — which is why grom saw no value on a quest reward or a loot row while his
+ // bag items had one. The id is also the earlier fact: it rides the packet, where
+ // `GetLootSlotLink` needs the template to have landed first.
+ g.set(
+ "GetLootSlotItemID",
+ lua.create_function(|lua, slot: usize| {
+ let model = lua.app_data_ref::().expect("model app_data");
+ let id = model
+ .loot
+ .as_ref()
+ .and_then(|l| slot.checked_sub(1).and_then(|n| l.rows.get(n)))
+ .and_then(|r| r.as_ref())
+ .map(|r| r.item_id)
+ .filter(|&id| id != 0);
+ match id {
+ Some(id) => Ok(Value::Integer(i64::from(id))),
+ None => Ok(Value::Nil),
+ }
+ })?,
+ )?;
+
// GetLootSlotLink(slot): nil out of range, for the coin row and while the template is in
// flight. The stock row click hands it to `DressUpItemLink` (`LootFrame.lua:149`) and
// `ChatFrameEditBox:Insert` (`:152`), both of which take a nil.
diff --git a/crates/benilla-ui/src/script/loot_roll.rs b/crates/benilla-ui/src/script/loot_roll.rs
index 06429478..b2cd0802 100644
--- a/crates/benilla-ui/src/script/loot_roll.rs
+++ b/crates/benilla-ui/src/script/loot_roll.rs
@@ -121,6 +121,27 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
+ // GetLootRollItemID(rollId) → the item id behind the row, or nil. Beyond 1.12, like `GetInventoryItemID`,
+ // and the family pfUI's vendor-price line is keyed on: `modules/sellvalue.lua` hooks each
+ // `Set*Item` tooltip verb and takes the row's **id**, never its link, then asks
+ // `C_Item.GetItemSellPriceByID`. A missing getter answers nil and `AddVendorPrices` returns on
+ // its first line — which is why grom saw no value on a quest reward or a loot row while his
+ // bag items had one. The id is also the earlier fact: it rides the packet, where
+ // `GetLootRollItemLink` needs the template to have landed first.
+ g.set(
+ "GetLootRollItemID",
+ lua.create_function(|lua, roll_id: u32| {
+ let model = lua.app_data_ref::().expect("model app_data");
+ match find(&model, roll_id)
+ .map(|r| r.item_id)
+ .filter(|&id| id != 0)
+ {
+ Some(id) => Ok(mlua::Value::Integer(i64::from(id))),
+ None => Ok(mlua::Value::Nil),
+ }
+ })?,
+ )?;
+
// 0 for an unknown id; the stock `OnUpdate` clamps it to the bar's minimum anyway
// (`LootFrame.lua:290-293`).
g.set(
diff --git a/crates/benilla-ui/src/script/merchant.rs b/crates/benilla-ui/src/script/merchant.rs
index dcb79645..171a5415 100644
--- a/crates/benilla-ui/src/script/merchant.rs
+++ b/crates/benilla-ui/src/script/merchant.rs
@@ -212,6 +212,29 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
+ // GetMerchantItemID(index) → the item id behind the row, or nil. Beyond 1.12, like `GetInventoryItemID`,
+ // and the family pfUI's vendor-price line is keyed on: `modules/sellvalue.lua` hooks each
+ // `Set*Item` tooltip verb and takes the row's **id**, never its link, then asks
+ // `C_Item.GetItemSellPriceByID`. A missing getter answers nil and `AddVendorPrices` returns on
+ // its first line. The id is also the earlier fact: it rides the packet, where
+ // `GetMerchantItemLink` needs the item's name to have arrived first.
+ g.set(
+ "GetMerchantItemID",
+ lua.create_function(|lua, index: usize| {
+ let model = lua.app_data_ref::().expect("model app_data");
+ let id = model
+ .merchant
+ .as_ref()
+ .and_then(|m| index.checked_sub(1).and_then(|n| m.items.get(n)))
+ .map(|it| it.item_id)
+ .filter(|&id| id != 0);
+ match id {
+ Some(id) => Ok(Value::Integer(i64::from(id))),
+ None => Ok(Value::Nil),
+ }
+ })?,
+ )?;
+
// GetMerchantItemLink(index): nil out of range and while in flight; both stock row-click
// arms accept a nil (`MerchantFrame.lua:303`, `:306`).
g.set(
diff --git a/crates/benilla-ui/src/script/quest.rs b/crates/benilla-ui/src/script/quest.rs
index 579d4e43..aa25695f 100644
--- a/crates/benilla-ui/src/script/quest.rs
+++ b/crates/benilla-ui/src/script/quest.rs
@@ -356,6 +356,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
+ // GetQuestItemID(type, index) → the item id behind the row, or nil.
+ //
+ // Beyond 1.12, like the `GetInventoryItemID` this client already answers, and the family is
+ // what pfUI's vendor-price line is keyed on: `modules/sellvalue.lua` hooks each `Set*Item`
+ // tooltip verb and takes the row's **id** — never its link — then asks
+ // `C_Item.GetItemSellPriceByID`. An absent getter answers nil and `AddVendorPrices` returns
+ // on its first line, which is why grom saw no value on a quest reward while his bag items had
+ // one: the two getters this client already had were `GetContainerItemID` and
+ // `GetInventoryItemID`.
+ //
+ // The id, not the link, is also the **earlier** fact: it comes off the packet, where the link
+ // needs the item's template to have landed for its name and quality. So this answers on the
+ // first hover, where `GetQuestItemLink` beside it still cannot.
+ g.set(
+ "GetQuestItemID",
+ lua.create_function(|lua, (kind, index): (String, usize)| {
+ let model = lua.app_data_ref::().expect("model app_data");
+ let id = model.quest.as_ref().and_then(|q| {
+ item_vec(q, &kind)
+ .and_then(|v| index.checked_sub(1).and_then(|n| v.get(n)))
+ .map(|it| it.item_id)
+ .filter(|&id| id != 0)
+ });
+ match id {
+ Some(id) => Ok(Value::Integer(i64::from(id))),
+ None => Ok(Value::Nil),
+ }
+ })?,
+ )?;
+
// GetRewardSpell() (`0x501df0`): three nils without a spell.
g.set(
"GetRewardSpell",
@@ -515,6 +545,74 @@ mod tests {
}
}
+ /// `GetQuestItemID` answers where `GetQuestItemLink` cannot — which is the whole reason the
+ /// `Get*ItemID` family exists here.
+ ///
+ /// pfUI's vendor-price line (`modules/sellvalue.lua`) hooks `SetQuestItem` and takes the row's
+ /// **id**, then asks `C_Item.GetItemSellPriceByID`; it never looks at the link. An item id
+ /// rides the quest packet, while the link is built only once the item's template has landed
+ /// (`ui_quest::resolve_item`: "`None` until the template gives the name and quality"), so a
+ /// reward hovered before its template arrives has an id and no link. Answering nil there is
+ /// what left grom with no value on a quest reward while his bag items had one.
+ #[test]
+ fn the_quest_item_id_answers_before_the_link_can() {
+ let mut s = UiScript::new().unwrap();
+ s.set_quest(Some(QuestState {
+ panel: QuestPanel::Detail,
+ rewards: vec![
+ // Template landed: both getters answer.
+ QuestItemView {
+ item_id: 7278,
+ name: Some("Brdle Leather Boots".into()),
+ quality: 2,
+ count: 1,
+ link: Some("|cff1eff00|Hitem:7278:0:0:0|h[Brdle Leather Boots]|h|r".into()),
+ ..Default::default()
+ },
+ // Template in flight: the id is known from the packet, the link is not yet built.
+ QuestItemView {
+ item_id: 15196,
+ name: None,
+ quality: 1,
+ count: 1,
+ link: None,
+ ..Default::default()
+ },
+ ],
+ ..Default::default()
+ }));
+
+ assert_eq!(
+ s.eval::(r#"return GetQuestItemID("reward", 1)"#)
+ .unwrap(),
+ 7278
+ );
+ assert_eq!(
+ s.eval::(r#"return GetQuestItemID("reward", 2)"#)
+ .unwrap(),
+ 15196,
+ "the id is known from the packet, so it answers with no template"
+ );
+ assert_eq!(
+ s.arity(r#"GetQuestItemLink("reward", 2)"#).unwrap(),
+ 1,
+ "…while the link is a lone nil, which is what the price line stopped on"
+ );
+ assert!(s
+ .eval::(r#"return GetQuestItemLink("reward", 2) == nil"#)
+ .unwrap());
+ assert!(
+ s.eval::(r#"return GetQuestItemID("reward", 3) == nil"#)
+ .unwrap(),
+ "out of range is nil"
+ );
+ assert!(
+ s.eval::(r#"return GetQuestItemID("nonsense", 1) == nil"#)
+ .unwrap(),
+ "an unknown row type is nil"
+ );
+ }
+
#[test]
fn background_material_answers_the_pushed_material_or_nil() {
let mut s = UiScript::new().unwrap();
diff --git a/crates/benilla-ui/src/script/quest_log.rs b/crates/benilla-ui/src/script/quest_log.rs
index 9eb92b5e..38eb9009 100644
--- a/crates/benilla-ui/src/script/quest_log.rs
+++ b/crates/benilla-ui/src/script/quest_log.rs
@@ -400,6 +400,36 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
install_detail_item(lua, "GetQuestLogChoiceInfo", |d| &d.choices)?;
install_detail_item(lua, "GetQuestLogRewardInfo", |d| &d.rewards)?;
+ // GetQuestLogItemID(type, index) → the item id behind the row, or nil. Beyond 1.12, like `GetInventoryItemID`,
+ // and the family pfUI's vendor-price line is keyed on: `modules/sellvalue.lua` hooks each
+ // `Set*Item` tooltip verb and takes the row's **id**, never its link, then asks
+ // `C_Item.GetItemSellPriceByID`. A missing getter answers nil and `AddVendorPrices` returns on
+ // its first line — which is why grom saw no value on a quest reward or a loot row while his
+ // bag items had one. The id is also the earlier fact: it rides the packet, where
+ // `GetQuestLogItemLink` needs the template to have landed first.
+ g.set(
+ "GetQuestLogItemID",
+ lua.create_function(|lua, (kind, index): (String, usize)| {
+ let model = lua.app_data_ref::().expect("model app_data");
+ let id = model.selected_quest_detail().and_then(|d| {
+ let v = match kind.as_str() {
+ "choice" => &d.choices,
+ "reward" => &d.rewards,
+ _ => return None,
+ };
+ index
+ .checked_sub(1)
+ .and_then(|n| v.get(n))
+ .map(|it| it.item_id)
+ .filter(|&id| id != 0)
+ });
+ match id {
+ Some(id) => Ok(Value::Integer(i64::from(id))),
+ None => Ok(Value::Nil),
+ }
+ })?,
+ )?;
+
// The item's escaped link, for the reward rows' ctrl and shift clicks (`QuestLogFrame.lua:545`,
// `:549`), keyed by `this.type`, "choice" or "reward" (set by `QuestFrameItems_Update`). An
// unknown type, an index out of range or a template not yet arrived answers nil.
diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs
index ee403a3c..73bb53da 100644
--- a/crates/benilla-ui/src/script/tests/reference_surface.rs
+++ b/crates/benilla-ui/src/script/tests/reference_surface.rs
@@ -62,7 +62,25 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
// commands); each of the rest awaits its 1.12 equivalent, removal or a reason.
"CancelUnitBuff",
"GetCursorInfo",
+ // The `Get*ItemID` family. pfUI's vendor-price line is keyed on it — `modules/sellvalue.lua`
+ // hooks each `Set*Item` tooltip verb and takes the row's **id**, never its link, then asks
+ // `C_Item.GetItemSellPriceByID` — so a missing one answers nil and `AddVendorPrices`
+ // returns on its first line. That was grom's "no item values on quest-reward tooltips":
+ // the only two of these this client had were `GetContainerItemID` (in `C_Container`) and
+ // `GetInventoryItemID`, so his bag and equipped items had a price line and his quest
+ // rewards, loot rows, loot rolls, merchant rows, mail attachments and trade-skill rows did
+ // not. Each is a reader over state already held, beside that row's own `*Link` getter, and
+ // each answers **earlier** than the link does: an id rides the packet, a link waits for the
+ // item's template.
+ "GetInboxItemID",
"GetInventoryItemID",
+ "GetLootRollItemID",
+ "GetLootSlotItemID",
+ "GetMerchantItemID",
+ "GetQuestItemID",
+ "GetQuestLogItemID",
+ "GetTradeSkillItemID",
+ "GetTradeSkillReagentItemID",
"GetPlayerFacing",
"GetTradePartnerName",
"IsGossipOptionCoded",
diff --git a/crates/benilla-ui/src/script/tradeskill/api.rs b/crates/benilla-ui/src/script/tradeskill/api.rs
index 242d7c68..6a4d48bb 100644
--- a/crates/benilla-ui/src/script/tradeskill/api.rs
+++ b/crates/benilla-ui/src/script/tradeskill/api.rs
@@ -158,6 +158,63 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
+ // GetTradeSkillItemID(index) / GetTradeSkillReagentItemID(index, reagentIndex) → the item id,
+ // or nil. Beyond 1.12, like `GetInventoryItemID`, and the family pfUI's vendor-price line is
+ // keyed on: `modules/sellvalue.lua` hooks each `Set*Item` tooltip verb and takes the row's
+ // **id**, never its link, then asks `C_Item.GetItemSellPriceByID`. A missing getter answers
+ // nil and `AddVendorPrices` returns on its first line.
+ //
+ // The product id is `product_item`, 0 for an enchant, which makes no item and so has none —
+ // the same corner `GetTradeSkillItemLink` above answers with a `spell:` link for. A reagent's
+ // id is known from the recipe itself, where the **link** beside it needs the reagent's
+ // template to be in `item_templates` first.
+ g.set(
+ "GetTradeSkillItemID",
+ lua.create_function(|lua, index: Value| {
+ let index = number_arg(lua, index, "Usage: GetTradeSkillItemID(index)")?;
+ let model = lua.app_data_ref::().expect("model app_data");
+ let id = usize::try_from(index)
+ .ok()
+ .and_then(|i| i.checked_sub(1))
+ .and_then(|n| rows(&model).get(n).cloned())
+ .and_then(|row| match row {
+ Row::Header { .. } => None,
+ Row::Entry(ei) => model.trade_skill.as_ref().map(|t| &t.recipes[ei]),
+ })
+ .map(|r| r.product_item)
+ .filter(|&id| id != 0);
+ Ok(match id {
+ Some(id) => Value::Integer(i64::from(id)),
+ None => Value::Nil,
+ })
+ })?,
+ )?;
+ g.set(
+ "GetTradeSkillReagentItemID",
+ lua.create_function(|lua, (index, reagent): (Value, Value)| {
+ let usage = "Usage: GetTradeSkillReagentItemID(index, reagentIndex)";
+ let index = number_arg(lua, index, usage)?;
+ let reagent = number_arg(lua, reagent, usage)?;
+ let model = lua.app_data_ref::().expect("model app_data");
+ let id = usize::try_from(index)
+ .ok()
+ .and_then(|i| i.checked_sub(1))
+ .and_then(|n| rows(&model).get(n).cloned())
+ .and_then(|row| match row {
+ Row::Header { .. } => None,
+ Row::Entry(ei) => model.trade_skill.as_ref().map(|t| &t.recipes[ei]),
+ })
+ .zip(usize::try_from(reagent).ok().and_then(|r| r.checked_sub(1)))
+ .and_then(|(r, ri)| r.reagents.get(ri))
+ .map(|re| re.item)
+ .filter(|&id| id != 0);
+ Ok(match id {
+ Some(id) => Value::Integer(i64::from(id)),
+ None => Value::Nil,
+ })
+ })?,
+ )?;
+
// GetTradeSkillInfo(index): (name, "header", 0, isExpanded) for a header, (name, difficulty,
// numAvailable, nil) for a recipe, one nil out of range.
g.set(
diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md
index 88a3581d..ee36f3f2 100644
--- a/docs/ADDON_COMPAT.md
+++ b/docs/ADDON_COMPAT.md
@@ -271,6 +271,7 @@ separately, so "cannot say" has to be distinguishable from "no".
| `GetSpellBonusDamage` | done | `script/char_stats.rs`, beside the `UnitDamage` that reads the same wire field. A school's spell power: 1.12 has no character-sheet line for it and no global to ask, which is why it reads as a later-client function — but **the number is already streamed**. A vanilla server folds item spell power into `PLAYER_FIELD_MOD_DAMAGE_DONE_POS`/`_NEG` (`_ApplyItemBonuses` → `ApplySpellDamageBonus`), the very field `UnitDamage` already reports index 0 of as the physical pos/neg pair, so schools `[1..7]` were arriving and being dropped at the feed: `ui_char.rs` read `player_mod_damage_done_pos(0)` alone. The snapshot now carries `damage_bonus_pos`/`_neg` as `[i32; 7]` (the shape `resistance_pos`/`_neg` already use) and `UnitDamage` takes `[0]` of it, so there is one model of the field rather than a scalar and a derived duplicate. **Why it mattered**: pfUI's player module polls schools 2..7 every 0.25s from its throttled `OnUpdate` to draw a "+N SP" readout (`modules/player.lua:92`), so on a caster the missing global raised *per tick* — 433 runtime errors in one logged session on a fresh warlock, the single largest error source in the log, and invisible on a warrior because the readout resolves to 0 and never shows. Schools are 1-based (1 physical … 7 arcane) and anything off that range answers **0, not nil and not a raise**, which is what the call site's own `or 0` assumes. Its sibling `GetSpellBonusHealing` stays **unbuilt and is not approximable**: vanilla has no healing player field at all — 0x40A is `PLAYER_FIELD_BYTES`, the healing-done fields are a 2.0 addition — so there is nothing on the wire to read, and the number would have to be summed from equipped item stats. Nothing installed calls it. `UnitSpellHaste`, which the same pfUI function calls one line earlier, stays `todo` for the reason its own row gives (no haste stat exists in 1.12); it is reached only when that module's haste display is switched on, which this player's config has off |
| `GetAddOnEnableState` (the legacy global) | **deliberately not done — checked, not assumed** | Upstream's own `docs/API.md` AddOns section lists the ten entries above and **not** this one, and keeps 1.12's `GetAddOnInfo` with `enabled` 4th. So the DLL actually loaded on this install does not define it either: adding it here would be a *divergence from the player's own client*, not parity with it. It would also break the one addon that asked for it. `AI_VoiceOver/Compatibility.lua:121` gates **two** definitions on one `if not GetAddOnEnableState` — its own enable-state fallback *and* a modern-shaped `GetAddOnInfo` that drops the `enabled` return. Define the global and VoiceOver keeps 1.12's 8-tuple while reading it at modern offsets, so `VoiceOver.lua:84`'s `select(5, GetAddOnInfo("VoiceOver")) ~= "MISSING"` reads `loadable` where it means `reason`, is true for a folder that is not installed, and raises the "delete your old VoiceOver folder" popup every login — twice, `AI_VoiceOver_112` too. Recorded because this was first diagnosed the other way round: the three `AI_VoiceOverData_*` modules look unloaded in the log and are not. They load inside AI_VoiceOver's own `ADDON_LOADED`, 224 ms of it, before the walk ever reaches their rows — and the walk's `is LoadOnDemand — not loaded (no LoadAddOn() yet)` line used to be printed unconditionally, so it went on saying "not loaded" about addons already in memory. That line is now gated on `UiScript::addon_is_loaded` |
| `C_Item.GetItemInfo` | done, delegating | `compat/classic_api/item.rs`. Calls the **stock 1.12 global** (`script/item_stats.rs`), which owns the template cache and the `CMSG_ITEM_QUERY` warm an uncached id records — so a modern caller feeds the one shared cache and a hook over the stock global is honoured, same discipline as `C_AddOns`. The shape is modern WoW's, by exactly the transform `item_stats.rs`'s own comment already names ("later clients insert `itemLevel` at 4"): returns 1..3 pass through, **position 4 is nil**, stock's 4..9 land at 5..10. Nil rather than a number because item level is not in `SMSG_ITEM_QUERY_SINGLE_RESPONSE` and 1.12 exposes no such concept (the same reason `GetCurrentItemLevel` stays unbuilt) — a fabricated 0 would read as a level-0 item where nil reads as "not known here", which a caller can tell apart. Modern positions and not 1.12's because a live call site against the real DLL pins them: pfUI's `modules/bags.lua:457` reads `local _, _, q, _, _, _, itype` and tests `itype == "Quest"` at `:475`, and position 7 is the subType *string* in the modern run against the stack *count* in 1.12's — forwarding the nine verbatim would have left that test silently never firing. Same evidentiary standard as the `ColorMixin` row below. Stops at 10: modern's 11th onward (sellPrice, classID, subclassID, bindType, …) are facts this client holds, but nothing available pins their *order* and each already has its own `*ByID` entry, so they are left off rather than placed from memory |
+| The `Get*ItemID` family (8 bare globals) | done | `GetQuestItemID`, `GetQuestLogItemID`, `GetLootSlotItemID`, `GetLootRollItemID`, `GetMerchantItemID`, `GetInboxItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID` — each a reader beside that row's own `*Link` getter, in its own domain module (`quest.rs`, `quest_log.rs`, `loot.rs`, `loot_roll.rs`, `merchant.rs`, `compat/classic_api/merchant_frame.rs`, `tradeskill/api.rs`), over state already held. Not 1.12 names: 1.12 exposes a row's item only as a **link**, and only once the item's template has landed to give it a name and quality. **pfUI's vendor-price line is keyed on the id, never the link** — `modules/sellvalue.lua` hooks each `Set*Item` tooltip verb, takes the row's id and asks `C_Item.GetItemSellPriceByID`, so a missing getter answers nil and `AddVendorPrices` returns on its first line. That was grom's "no item values on quest-reward tooltips", and it was never about quests: the only two of these this client had were `C_Container.GetContainerItemID` and `GetInventoryItemID`, so bag and equipped items had a price line and quest rewards, loot rows, loot rolls, merchant rows, mail attachments and trade-skill rows did not. The id is also the **earlier** fact — it rides the packet where the link waits on the template — so these answer on the first hover, which the `*Link` getter beside each of them cannot (`quest::tests::the_quest_item_id_answers_before_the_link_can`). A trade-skill **enchant** answers nil, having no product item, the same corner `GetTradeSkillItemLink` covers with a `spell:` link |
| `IsSpellKnown` (bare) | done | `compat/classic_api/spell.rs`'s new `install_globals`, matching upstream's own heading "Spell (`C_Spell`, plus several bare globals)". It hands out **the same function value** `C_Spell.IsSpellKnown` holds, lifted out of that table rather than written twice — so the two spellings cannot drift and there is no second body to maintain (`the_bare_is_spell_known_is_the_very_function_c_spell_holds` asserts the identity, not just the behaviour). Not a 1.12 name: 1.12 asks "do I know this" only by walking the book with `GetSpellName(i, "spell")`, never by id. pfUI's `modules/bags.lua:703` calls it bare, 3 occurrences in one session |
| `GetMacroSpell` (bare) | done — **the "not built" note in `macro_ext.rs` was wrong about this client** | `compat/classic_api/macro_ext.rs`'s `install_globals`. The triage item that produced this said `C_Macro.GetMacroSpell` already existed and only needed exposing; it did not exist anywhere, and that file's header gave a reason — "resolving a body's first `/cast` line needs the rank-aware spellbook lookup `C_Spell`'s row notes is unbuilt". **Both halves do exist and are already load-bearing.** `benilla-app`'s `ui_macro/run.rs` is a traced reimplementation of the reference's own macro→spell derivation (`0x4efe00` writing `[rec+0x564]`, reading the `SLASH_CAST%d` aliases as Lua globals exactly as `0x703bf0` does, plus a `CastSpellByName(` arm) and resolves through `spellbook.rs`'s `resolve_spell_by_name`, the same resolver `CastSpellByName` and `/cast` use; the action bar has been reading its result for a macro slot's cooldown, usability and range all along. So this is **not new derivation** — it is the existing one, fed into the VM as `Model::macro_bound_spells` by the existing `rebind_macro_spells` system (no new system, no new file, no ceiling movement) and read back by id through the same `spell_display` `C_Spell.GetSpellName` uses. Returns `(name, rank)`, two values only: pfUI reads `effect, rank = GetMacroSpell(id)` (`libs/libpredict.lua:1027`) and compares the first to a spell name (`modules/swingtimer.lua:430`), so a bare-spellID return (what the newest clients give) would fail both, and no third value is appended because nothing pins its position. Rank is a string, `""` when rankless, the rule the stock `GetSpellName` already keeps. Only resolved spells cross the feed: `BoundSpell::None` and `BoundSpell::Unresolved` are both "no spell" to Lua, which has one way to say it, while the bar needs them apart. Stated latency: one frame after an in-Lua `EditMacro`, the identical delay the action bar already has, from the identical source. `GetMacroItem` stays unbuilt — the reference's macro record binds a spell and has no item counterpart to derive |
| `GetInventoryItemRepairCost` | todo, blocked | pfUI reaches it in the *hover* arm of the durability panel widget the two `INVSLOT_*` constants above unblocked (`modules/panel.lua:416`), guarded as `(GetInventoryItemRepairCost(id) or 0)` — which does not help, since a nil *global* raises before `or` runs. Deliberately not built, and not stubbed to 0: 1.12 computes repair cost **server-side only** and the client never receives or derives a per-item figure (the same blocker already recorded for `C_Container.GetContainerItemRepairCost`, `container.rs`'s header). `0` would read as "this item repairs free", which is a wrong answer a caller cannot detect, where a nil-call error names the function. The client-side repair numbers that *do* exist are the merchant window's own `CanMerchantRepair`/`GetRepairAllCost`, which are a different fact (the whole-inventory total at an open vendor) and cannot be divided back into per-slot costs |