the vendor and gossip windows are the reference's own

- the vendor window is the reference's own `MerchantFrame.xml`, which took three things in order: the vendor cursor, the coin kit's move, and `GameTooltip:SetMerchantCompareItem` — where `offset` is the ordinal among CANDIDATE slots (empty ones skipped without decrementing) and a candidate matches by item CLASS and never by entry (two of four RE workers read it as an entry match, which would have fired the compare tooltip only when you already wear the identical item, i.e. never, with the window looking finished); two visible changes ride with the reference's rows — the coin columns take `SmallMoneyFrameTemplate`'s own geometry, and only the ICON takes the mouse, so hovering a row's name shows nothing, which is the client's behaviour
- the gossip window is the reference's own, and its deferral was wrong: the "2px rows" were a harness gap (a bare test VM with no text measurer, while the app always installs one), not an engine one; four things the reference does that ours did not are pinned — a hidden SPACER row between the quest groups, coded options are NOT greyed (a coded row is clickable, and the send path's missing code is named as its own gap), the row label is the Button's own text, and the scroll child is not grown
- the CAMP/QUIT dialogs and the PARTY_INVITE dialog move to `StaticPopup`'s home — the fourth such rehome of a dialog that had lived in whichever window first needed it — and moving them found a real bug: `button1 = CANCEL` captured a nil at load because `CANCEL` was declared below the insertion point, so the popup engine fell back and put "Yes" on the camp dialog's cancel button
- three instruments: the gap report stops counting commented-out calls (`ShowInventorySellCursor` blocked the character sheet on three commented lines — a whole RE round spent on an over-report), it runs the load itself and answers in ONE table (29 "free" became 24 actually unblocked), and a shadow report predicts which swaps will fail by intersecting the names our files define with the names the chain files in our own manifest define — 38 collisions across 5 files, ours winning every one

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:01:00 +07:00
co-authored by Claude Fable 5.1
parent f01a964e6e
commit 112e82d276
32 changed files with 1139 additions and 2381 deletions
@@ -355,3 +355,206 @@ fn set_trade_skill_item_indexes_visible_rows_not_raw_recipes() {
// rather than a neighbouring recipe's tooltip appearing under the cursor.
assert_eq!(name_at(&mut s, 4, ""), "Alpha Cloak Reagent");
}
/// `SetMerchantCompareItem(index [, offset])` — the shopping tooltip the stock vendor row raises.
///
/// The contract that decides ghost tooltips versus none is the RETURN, so that is what this pins:
/// the **number** 1 on success, `nil` on every failure, one value always. Stock's
/// `if ( ShoppingTooltip1:SetMerchantCompareItem(id, 1) ) then … end` reads it directly, so a
/// boolean `false` where nil belongs shows an empty tooltip and a truthy nil-case shows two.
#[test]
fn set_merchant_compare_item_answers_one_or_nil_per_candidate_slot() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
// Both finger slots worn, both class 4 (ARMOR) like the ring on the shelf.
let mut inv: InventorySlots = Default::default();
for (slot, id, name) in [(11usize, 7000u32, "Old Loop"), (12, 7001, "Older Loop")] {
inv[slot] = Some(InvSlotView {
item_id: id,
name: Some(name.into()),
quality: 1,
..Default::default()
});
s.set_item_template(
id,
ItemTemplateView {
name: name.into(),
quality: 1,
class: 4,
inventory_type: 11,
..Default::default()
},
);
}
// …and a shield, class 4, in the off hand — the case that makes offset 2 nil for a WEAPON.
inv[17] = Some(InvSlotView {
item_id: 7100,
name: Some("Battered Buckler".into()),
quality: 1,
..Default::default()
});
s.set_item_template(
7100,
ItemTemplateView {
name: "Battered Buckler".into(),
quality: 1,
class: 4, // ARMOR
inventory_type: 14,
..Default::default()
},
);
inv[16] = Some(InvSlotView {
item_id: 7101,
name: Some("Bent Sword".into()),
quality: 1,
..Default::default()
});
s.set_item_template(
7101,
ItemTemplateView {
name: "Bent Sword".into(),
quality: 1,
class: 2, // WEAPON
inventory_type: 13,
..Default::default()
},
);
s.set_inventory_slots(inv);
s.set_merchant(Some(MerchantState {
items: vec![
MerchantItem {
name: Some("Shiny Loop".into()),
item_id: 8000,
..Default::default()
},
MerchantItem {
name: Some("Sharp Sword".into()),
item_id: 8001,
..Default::default()
},
MerchantItem {
name: Some("Unseen Thing".into()),
item_id: 8999, // no template — the uncached leg
..Default::default()
},
],
..Default::default()
}));
s.set_item_template(
8000,
ItemTemplateView {
name: "Shiny Loop".into(),
quality: 3,
class: 4,
inventory_type: 11, // finger — two candidate slots
..Default::default()
},
);
s.set_item_template(
8001,
ItemTemplateView {
name: "Sharp Sword".into(),
quality: 3,
class: 2,
inventory_type: 13, // one-hand — main hand then off hand
..Default::default()
},
);
// The two shopping tooltips are ordinary GameTooltip frames — the engine knows nothing about
// them (the substring `ShoppingTooltip` does not occur in the image at all).
s.run(
r#"
CreateFrame("GameTooltip", "ShoppingTooltip1"):Hide()
CreateFrame("GameTooltip", "ShoppingTooltip2"):Hide()
"#,
)
.unwrap();
let call =
|s: &mut UiScript, expr: &str| s.eval::<String>(&format!("return type({expr})")).unwrap();
// A ring against two worn rings: both offsets answer, and the answer is the NUMBER 1.
assert_eq!(
s.eval::<String>(r#"return tostring(ShoppingTooltip1:SetMerchantCompareItem(1, 1))"#)
.unwrap(),
"1",
"the truthy leg is lua_pushnumber(1.0), not a boolean"
);
assert_eq!(
call(&mut s, "ShoppingTooltip2:SetMerchantCompareItem(1, 2)"),
"number"
);
// …and it filled with the WORN item, headed by the gray compare line.
assert!(
s.eval::<bool>(
r#"local n = ShoppingTooltip1:NumLines()
for i = 1, n do
if getglobal("ShoppingTooltip1TextLeft"..i):GetText() == "Currently Equipped" then
return true
end
end
return false"#
)
.unwrap(),
"the fill is the equipped item's own tooltip with the CURRENTLY_EQUIPPED header"
);
// A one-hand weapon: main hand matches (class 2), the off-hand SHIELD does not (class 4), so
// offset 2 is nil. This is the reference's class test doing the work, and it is the case a
// slot-only reading would get wrong.
assert_eq!(
call(&mut s, "ShoppingTooltip1:SetMerchantCompareItem(2, 1)"),
"number"
);
assert_eq!(
call(&mut s, "ShoppingTooltip1:SetMerchantCompareItem(2, 2)"),
"nil",
"a shield is not a candidate for a weapon — class, not slot"
);
// Offset defaults to 1 when absent, and does NOT raise.
assert_eq!(
call(&mut s, "ShoppingTooltip1:SetMerchantCompareItem(1)"),
"number"
);
// An explicit 0 or a negative is nil — the counter starts below zero and only decrements.
for bad in ["0", "-1"] {
assert_eq!(
call(
&mut s,
&format!("ShoppingTooltip1:SetMerchantCompareItem(1, {bad})")
),
"nil",
"offset {bad}"
);
}
// Out of range, and the uncached template, are both nil — never an error.
for expr in [
"ShoppingTooltip1:SetMerchantCompareItem(0, 1)",
"ShoppingTooltip1:SetMerchantCompareItem(99, 1)",
"ShoppingTooltip1:SetMerchantCompareItem(3, 1)",
] {
assert_eq!(call(&mut s, expr), "nil", "{expr}");
}
// 2.7 re-bases in f64 and THEN truncates: 2.7 - 1 = 1.7 -> row 1, the sword. Doing it the
// other way round would land on row 2.
assert_eq!(
call(&mut s, "ShoppingTooltip1:SetMerchantCompareItem(2.7, 1)"),
"number"
);
// A non-number index RAISES, with the client's own usage text.
let e = s
.run(r#"ShoppingTooltip1:SetMerchantCompareItem({}, 1)"#)
.unwrap_err()
.to_string();
assert!(e.contains("Usage: SetMerchantCompareItem"), "{e}");
// …but a numeric STRING is a number to `lua_isnumber`.
assert_eq!(
call(&mut s, r#"ShoppingTooltip1:SetMerchantCompareItem("1", 1)"#),
"number"
);
}
+161 -18
View File
@@ -161,24 +161,30 @@ fn render_by_id(
/// `ui_items::find_equip_slot` (the equip-click fit rule) — one law, two consumers.
fn equip_slots_for(inventory_type: u32) -> &'static [u32] {
match inventory_type {
1 => &[1], // head
2 => &[2], // neck
3 => &[3], // shoulder
4 => &[4], // shirt
5 | 20 => &[5], // chest / robe
6 => &[6], // waist
7 => &[7], // legs
8 => &[8], // feet
9 => &[9], // wrist
10 => &[10], // hands
11 => &[11, 12], // finger
12 => &[13, 14], // trinket
16 => &[15], // back
19 => &[19], // tabard
13 => &[16, 17], // one-hand
21 => &[16], // main hand
14 | 22 | 23 => &[17], // shield / off-hand weapon / held
17 => &[16, 17], // two-hand (displaces both hands)
1 => &[1], // head
2 => &[2], // neck
3 => &[3], // shoulder
4 => &[4], // shirt
5 | 20 => &[5], // chest / robe
6 => &[6], // waist
7 => &[7], // legs
8 => &[8], // feet
9 => &[9], // wrist
10 => &[10], // hands
11 => &[11, 12], // finger
12 => &[13, 14], // trinket
16 => &[15], // back
19 => &[19], // tabard
13 => &[16, 17], // one-hand
21 => &[16], // main hand
14 | 22 | 23 => &[17], // shield / off-hand weapon / held
// Two-hand: **main hand ONLY**, which is not the obvious answer. A 2H displaces both
// hands when equipped, and this table was written `[16, 17]` on that reasoning. The
// reference's own mask disagrees: `0x809200[17]` is `0x8000`, bit 15, main hand alone
// (wow-re `merchant-compare-item-law.md` §3). It is a COMPARE-CANDIDATE table, not an
// occupancy one — the question it answers is "which worn item is this offering to
// replace", and for a two-hander that is the weapon in your main hand.
17 => &[16],
15 | 25 | 26 | 28 => &[18], // bow / thrown / wand-gun / relic
_ => &[],
}
@@ -677,6 +683,143 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// GameTooltip:SetMerchantCompareItem(index [, offset]) — `0x536080`, GameTooltip method-table
// entry 43 (wow-re `system/ui/scratch/merchant-compare-item-law.md`). The shopping tooltip the
// stock vendor row raises beside the main one: "here is what you are wearing that this would
// replace".
//
// **`offset` is not a slot.** It is the 1-based ordinal among CANDIDATE slots, and a slot is a
// candidate only when all three of these hold — the reference's own conjunction:
//
// 1. its bit is set in `0x809200[InventoryType]` (our [`equip_slots_for`]),
// 2. the slot is OCCUPIED,
// 3. the equipped item's **class** equals the vendor item's class. Class only — subclass is
// never read.
//
// Empty slots are skipped **without** decrementing, so offset 1 finds the first occupied
// matching slot wherever it sits. The consequences are the reference's and they are not
// obvious: a shield in the off hand makes offset 2 nil for a one-hand weapon (ARMOR ≠ WEAPON),
// and an equipped wand DOES compare against a bow on the shelf (both class 2, both bit 17).
// Any type with one candidate slot answers nil for offset 2, which is what makes stock's
// second `if` cheap.
//
// **The return contract decides ghost tooltips versus none**, so it is transcribed exactly:
// ONE Lua value on every non-raising path — the NUMBER 1 on success (`lua_pushnumber`, not a
// boolean), `nil` on every failure. A bad `this` or a non-number index RAISES; a missing
// `offset` does not, and defaults to 1. An explicit `offset` of 0 or less is nil, because the
// counter starts below zero and only ever decrements.
//
// An uncached vendor template answers nil AND fires the query ([`view_of`] does both), so the
// first hover of an unseen vendor item shows no shopping tooltip and a later one does. That is
// the reference's behaviour, not a race we lost.
m.set(
"SetMerchantCompareItem",
lua.create_function(
|lua, (this, index, offset): (Table, Value, Option<Value>)| {
let h = frame_handle_of(lua, &this)?;
let as_number = |v: &Value| -> Option<f64> {
match v {
Value::Integer(i) => Some(*i as f64),
Value::Number(n) => Some(*n),
// `lua_isnumber` accepts a numeric string, here as everywhere.
Value::String(s) => s.to_str().ok().and_then(|s| s.parse::<f64>().ok()),
_ => None,
}
};
// The one argument whose absence is an ERROR, with the client's own usage text.
let Some(idx) = as_number(&index) else {
return Err(mlua::Error::runtime(
"Usage: SetMerchantCompareItem(\"slot\" [, offset])",
));
};
// The reference re-bases the index in f64 and THEN truncates toward zero, so 2.7
// is row 1 (1.7 → 1) rather than row 2. Doing it the other way round differs by
// one for every fractional index.
let row = (idx - 1.0).trunc();
if row.is_nan() || row < 0.0 || row > f64::from(u32::MAX) {
return Ok(Value::Nil);
}
// `offset` re-bases as an INTEGER, and its absence means 1.
let mut left = match offset.as_ref().and_then(as_number) {
Some(n) if n.is_nan() => return Ok(Value::Nil),
Some(n) => n.trunc() - 1.0,
None => 0.0,
};
if left < 0.0 {
return Ok(Value::Nil);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let row = row as usize;
let Some(item_id) = ({
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.merchant
.as_ref()
.and_then(|m| m.items.get(row))
.map(|it| it.item_id)
}) else {
return Ok(Value::Nil);
};
// The vendor item's own template — the class this compares by, and the miss that
// fires the query.
let Some(offered) = view_of(lua, item_id) else {
return Ok(Value::Nil);
};
let found = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let mut hit = None;
for &slot in equip_slots_for(offered.inventory_type) {
let Some(worn) = model
.inv_slot("player", slot as usize)
.filter(|s| s.item_id != 0)
else {
continue; // empty: skipped WITHOUT decrementing
};
let same_class = model
.item_templates
.get(&worn.item_id)
.is_some_and(|w| w.class == offered.class);
if !same_class {
continue;
}
if left <= 0.0 {
hit = Some(slot as usize);
break;
}
left -= 1.0;
}
hit
};
let Some(slot) = found else {
// A failing call does NOT clear the tooltip — the reference's nil exit pushes
// nil and touches nothing else.
return Ok(Value::Nil);
};
// …and the fill is the ordinary EQUIPPED-item tooltip with the compare header
// on: the FULL body, because compact mode is off (`p4 = 0`) and there is no delta
// arithmetic anywhere in the client — it draws two whole tooltips side by side —
// plus one gray `CURRENTLY_EQUIPPED` line first (`p5 = 1`).
//
// Reached by arming the same `compare_armed` latch the shift-compare path uses and
// running `SetInventoryItem`, which is what the reference does too: `0x536080`
// calls the ordinary item builder `0x52b650` with the header flag set, not a
// second builder of its own.
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Ok(t) = super::tooltip::tip_mut(&mut model, h) {
t.compare_armed = true;
}
}
this.get::<mlua::Function>("SetInventoryItem")?
.call::<mlua::MultiValue>((this.clone(), "player", slot))?;
Ok(Value::Integer(1))
},
)?,
)?;
// GameTooltip:SetMerchantItem(index) / SetBuybackItem(index) — template-id sources through
// the same store (the real SetMerchantItem is the template path — tooltip-money.md); the
// merchant feed's own stat head is the fallback while the template answer is in flight, so