Name a quest reward row that resolves to nothing, rather than painting an empty button

grom photographed "The Tower of Azora" (quest 41747) offering Scroll of Stamina, Scroll
of Spirit, and a third button with no icon and no name — an empty bordered box.

A reward row's two halves come from independent sources: the name from the item template
(`Items::template`, which queries the server on a cache miss) and the icon from
`ItemDisplayInfo.dbc` through the wire's own `display_id`. A merely late template still
paints an icon, and the feed rebuilds its snapshot every frame the panel is open, so a
template arriving a second later would repaint. **Neither half answering** means the
triple itself carries nothing this client can resolve.

Ruled out from here, so the next person does not re-run them: the decode is a
count-prefixed run of triples (vmangos' own layout), so a mis-stride would have broken
rows 1 and 2 as well; `ItemDisplayCatalog` is a `HashMap<u32, _>` keyed by row id, so the
realm's custom 68xxx display range is not an indexing trap (its `ItemDisplayInfo.dbc` has
45,254 rows and a max id of 69,176, and 68793/68797 are both present); and the two scrolls
that *did* paint carry vanilla display ids (1093, 2616) while the plausible third choices
carry custom ones, which is suggestive but not evidence.

This lands as an instrument rather than a fix because **the reference does not filter
these either**. Its DETAILS publisher `0x500ef0` zeroes the six choice slots at `0xbdf028`,
copies exactly `count` entries in with no test on the item id (`500f41`-`500f58`), then
zero-fills the rest — so a server padding its reward list makes the reference draw the
same empty button. A padded wire and a template we failed to fetch look identical from the
painted frame and want opposite fixes, so guessing between them is how this becomes a
fourth wrong verdict.

`warn_unresolved_reward_once` prints the item id, count and displayId once per distinct
pair (the snapshot rebuilds per frame, so unguarded it would print at frame rate) and says
which of the two it is: an `item_id` of 0 is a padded wire and not ours, a real id is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-30 15:55:06 -06:00
co-authored by Claude Opus 5
parent f8e93c4484
commit a811f50e86
+51 -2
View File
@@ -279,6 +279,53 @@ pub(crate) fn row_is_one_click(icon: u32) -> bool {
type Pool = Vec<(u32, u32)>;
/// One wire item row for Lua; the name and quality are `None` and white until the template lands.
/// Reward rows already named by [`warn_unresolved_reward_once`]. The snapshot is rebuilt every
/// frame the panel is open, so without this the warning would print at frame rate.
static UNRESOLVED_REWARDS: std::sync::Mutex<std::collections::BTreeSet<(u32, u32)>> =
std::sync::Mutex::new(std::collections::BTreeSet::new());
/// A reward row that resolves to neither a name nor an icon paints as an **empty button**, which
/// is what grom photographed on "The Tower of Azora" (quest 41747): two scrolls offered and a
/// blank third box under them.
///
/// Both halves failing at once is the fact worth naming, because they come from independent
/// sources: the name from the item template ([`Items::template`], which queries the server on a
/// cache miss) and the icon from `ItemDisplayInfo.dbc` through the wire's own `display_id`. A
/// merely late template still paints an icon. Neither answering means the triple itself carries
/// nothing we can resolve.
///
/// An instrument and not a fix, deliberately: the reference does not filter these either. Its
/// DETAILS publisher (`0x500ef0`) zeroes the six choice slots at `0xbdf028`, copies exactly
/// `count` entries in with **no test on the item id** (`500f41`-`500f58`), and zero-fills the
/// rest — so a server padding its choice list makes the reference draw the same empty button.
/// Whether this is a padded wire or a template we failed to fetch cannot be told from the
/// painted frame, and the two want opposite fixes. One line at the next sighting says which.
fn warn_unresolved_reward_once(it: &QuestRewardItem, view: &QuestItemView) {
if view.name.is_some() || view.texture.is_some() {
return;
}
let Ok(mut seen) = UNRESOLVED_REWARDS.lock() else {
return;
};
if !seen.insert((it.item_id, it.display_id)) {
return;
}
warn!(
"ui_quest: a reward row resolved to neither a name nor an icon, so it paints as an empty \
button — item {} x{}, displayId {}. {}",
it.item_id,
it.count,
it.display_id,
if it.item_id == 0 {
"The wire sent an empty triple, so the server padded its reward list and the \
reference draws the same empty button — not ours to fix."
} else {
"The wire named a real item, so this one is ours: either its template never came \
back from the query, or `ItemDisplayInfo.dbc` has no row for that displayId."
}
);
}
fn resolve_item(
it: &QuestRewardItem,
items: &Items,
@@ -295,7 +342,7 @@ fn resolve_item(
let link = name
.as_ref()
.map(|n| crate::ui_items::item_link(it.item_id, n, quality));
QuestItemView {
let view = QuestItemView {
name,
texture,
count: it.count,
@@ -303,7 +350,9 @@ fn resolve_item(
item_id: it.item_id,
usable: true, // not computed; the stock frame tints unusable ones red
link,
}
};
warn_unresolved_reward_once(it, &view);
view
}
fn resolve_items(