The missing item values were eight absent id getters, not the tooltips

grom reported no item values on quest-reward tooltips. The value line is pfUI's,
and `modules/sellvalue.lua` says plainly how it works: it hooks every `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.

So it was never about quests. This client answered exactly two members of that
family, `C_Container.GetContainerItemID` and `GetInventoryItemID`, which is why
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. The
other eight are here: `GetQuestItemID`, `GetQuestLogItemID`,
`GetLootSlotItemID`, `GetLootRollItemID`, `GetMerchantItemID`,
`GetInboxItemID`, `GetTradeSkillItemID`, `GetTradeSkillReagentItemID`. Each sits
beside that row's own `*Link` getter, in that row's own module, reading state
already held — no new feed, no new state.

The id is also the **earlier** fact, which is the part worth keeping in mind. An
item id rides the packet; a link is built only once the item's template has
landed to give it a name and quality, and `ui_quest::resolve_item` says so in as
many words. A quest reward is close to the worst case for that — it is usually
an
item the player has never seen, so its template is not in the 14,000-row cache
and the first hover beats the answer home. These getters answer there, where the
link beside them is still nil; the test asserts exactly that contrast.

Also, from the same thread of play reports: the sell-junk sweep now 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 template arrives,
so a grey sitting in such a slot is neither counted nor sold — which is what
"not all grey items sell" looks like from outside. Whether that is in fact the
cause is not something this end can tell, so the sweep records the slots it
skipped for that reason, with their item ids, once per click. If the next log
names some, the fix is to wait for those templates; if it names none, the cause
is downstream in the drain or the server's refusal, and the line says that too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-28 22:13:28 -06:00
co-authored by Claude Opus 5
parent 3b8050a91d
commit b5259bd608
9 changed files with 385 additions and 0 deletions
@@ -135,6 +135,43 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
.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<String> = 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::<Model>().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<usize>)| {
@@ -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<u32>| 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::<i64>("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();
+25
View File
@@ -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::<Model>().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.
+21
View File
@@ -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::<Model>().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(
+23
View File
@@ -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::<Model>().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(
+98
View File
@@ -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::<Model>().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::<i64>(r#"return GetQuestItemID("reward", 1)"#)
.unwrap(),
7278
);
assert_eq!(
s.eval::<i64>(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::<bool>(r#"return GetQuestItemLink("reward", 2) == nil"#)
.unwrap());
assert!(
s.eval::<bool>(r#"return GetQuestItemID("reward", 3) == nil"#)
.unwrap(),
"out of range is nil"
);
assert!(
s.eval::<bool>(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();
+30
View File
@@ -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::<Model>().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.
@@ -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",
@@ -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::<Model>().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::<Model>().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(
+1
View File
@@ -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 |