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 |