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(