the auction house opens, the Honor tab lands, and a day of fixes

- the auction house end to end: right-clicking an auctioneer opens Browse/Bids/Auctions on the server's reply — search, the category tree from your own DBCs, the reference's 50-a-page pager, multi-key sorting (directions corrected at the bytes), bid/buyout, the sell slot with deposit and duration, cancel with its fee popup; prices sit on the reference's own money-frame kit in lined-up columns, the twenty auction messages split refusals-red / outcomes-to-chat, and a won or sold auction reads as a proper mail receipt — the window's Lua re-worded in our own terms after the overlap ratchet caught the first cut leaning on the reference's text
- the PvP honor system whole: the Honor tab and the inspect page over a real wire model, with current rank and highest-ever rank correctly two different fields
- a day of reported fixes: WASD survives a /logout re-entry, addon settings save on a window close and Cmd+Q (exactly once — a mac close could fire the tail twice), clams open again, random-enchant items name themselves on every surface, the loot window's rows hold their seats with legible counts, idle players stop sinking through building floors, the shooter re-pulls between Auto Shots, portraits loosen ~19% to the client's own aspect-1.0 matrix, and big creatures thump the camera off the CameraShakes table
- character select clicks like 1.12 — captions whiten on hover, buttons fire on release, Humans face straight ahead off the stage bone's real yaw, the facing is per-character, and hunter/warlock pets stand beside you at their family-ramp size with their own emitters (the imp's flames), the Voidwalker's sequence-hidden shackles culled
- the smaller player-facing set: nameplate on/off rows in Options with the CanAttack category gate (emissaries stop plating), the bonus bar slides up with igBonusBarOpen, the camera's framing height glides on a shapeshift, the open letter evicts the centre seat, the far-band fog hull stops printing a pale horizon stripe at low view distance, the guide arrow is gold with per-source rim art, error text fades through its shadow too, and /convertraid converts the party
- README: the auction house and PvP honor move out of "not built yet"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-22 19:04:27 +07:00
co-authored by Claude Fable 5
parent 6aa78bc468
commit cf581aa2ac
170 changed files with 20573 additions and 1578 deletions
File diff suppressed because it is too large Load Diff
+17
View File
@@ -94,6 +94,23 @@ pub struct EnchantView {
pub remaining_ms: Option<u64>,
}
/// One `ItemRandomProperties` row as the engine renders it — the **random-suffix roll** a dropped,
/// linked, auctioned or mailed item carries (decision 1547). The app resolves the DBC (both of
/// them: the roll's table and the `SpellItemEnchantment` names its five ids land on) and pushes the
/// whole table once at load; a tooltip source supplies only the id, exactly as in the reference.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RandomPropertyView {
/// The roll's suffix — `"of the Monkey"`. The reference joins it onto the item name through
/// `ITEM_SUFFIX_TEMPLATE` (`"%s %s"`) in its one name formatter `0x5d8b00`; benilla does that
/// join app-side, where every display name is composed, so this is carried for reference and
/// for the surfaces that name an item the app never composed.
pub suffix: String,
/// The enchant lines the roll grants, already named and seated in enchant slots **2..6** —
/// the five dwords the builder copies out of the suffix row (`0x52b7e0–0x52b7fb`). Slots 2..6
/// are always white, whatever the sign.
pub enchants: Vec<EnchantView>,
}
/// One bag's snapshot: its name, capacity, and occupied slots (1-based).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ContainerState {
+23
View File
@@ -88,6 +88,29 @@ pub struct CursorItem {
pub bar_placeable: bool,
}
/// How many items the cursor is actually holding, for a payload picked up from a bag.
///
/// [`CursorItem::count`] is `None` for a **whole-stack** pickup — it records only what a *split*
/// carry took — so reading it as `unwrap_or(1)` answers 1 for a stack of twenty. That is a
/// display wart on a mail attachment and a real arithmetic error on an auction deposit, which is
/// computed per stack: it would quote a twentieth of what the server then charges. So the true
/// size comes back from the source slot, which still holds the stack (a pickup locks the slot, it
/// does not empty it).
///
/// Falls back to 1 only when the source slot is genuinely unreadable — a bag the app has not
/// pushed, or a slot that changed under us.
pub(super) fn held_count(model: &super::Model, item: &CursorItem) -> u32 {
if let Some(n) = item.count {
return n.max(1);
}
model
.containers
.get(&item.bag)
.and_then(|c| c.slots.get(&item.slot))
.filter(|s| s.item_id == item.item_id)
.map_or(1, |s| s.count.max(1))
}
/// A spell payload ([`super::spellbook`]'s `PickupSpell` produces it): the spellbook slot it was
/// picked from, its book (`"spell"`/`"pet"`, the Era `GetCursorInfo` shape), and the resolved
/// spell id.
@@ -174,6 +174,23 @@ pub struct PlayerReqState {
pub honor_rank: u8,
}
/// [`item_usable`] for an item the caller knows only by **entry** — the shape every window seam
/// wants, since a row carries an id and the engine holds the templates.
///
/// Two conventions are folded in here rather than re-derived per caller: entry `0` (a row with no
/// item at all) is usable, and a template the app has not answered for yet is usable, which is the
/// getter's own null-record skip. It was four identical private copies across the merchant, mail,
/// trade and auction seams before it was one — three had the `item_id == 0` leg and the fourth
/// reached the same answer through `is_none_or`, which is exactly the kind of agreement that stops
/// being reliable the moment someone edits one of them.
pub(super) fn item_usable_by_id(model: &super::Model, item_id: u32) -> bool {
item_id == 0
|| model.item_templates.get(&item_id).is_none_or(|v| {
item_usable(v, &model.player_req, |id| {
model.spellbook.slots.iter().any(|s| s.spell_id == id)
})
})
}
/// The client's item-usable predicate `0x5ea930(player; itemCacheRecord, &err)` — byte-read from
/// wow-re's `ui/scratch/disasm-full.txt`. Both merchant getters call it (`GetMerchantItemInfo`
/// `0x4fb2a3`, `GetBuybackItemInfo` `0x4fb4f7`) and push `1`/`nil` as `isUsable`; the FrameXML
@@ -299,6 +316,16 @@ impl super::UiScript {
self.model_mut().item_set_asks.drain().collect()
}
/// Push the **whole** random-suffix table (`ItemRandomProperties` id → its resolved view) —
/// once, at load. Not an ask-once store: see [`super::Model::random_properties`] for why the
/// roll table is pushed whole where the template store is asked for.
pub fn set_random_properties(
&mut self,
rows: std::collections::HashMap<u32, super::RandomPropertyView>,
) {
self.model_mut().random_properties = rows;
}
/// Push the red-line law's player state (level/class/race/skills). Cheap to call on change.
pub fn set_player_req_state(&mut self, state: PlayerReqState) {
self.model_mut().player_req = state;
+54 -16
View File
@@ -58,13 +58,29 @@ pub struct LootRow {
/// `DressUpFrame.lua:10-16`) and the shift arm goes through `BenillaChatEdit_InsertLink`, which
/// drops it — our `EditBox:Insert` binding is typed `String` and would raise.
pub link: Option<String>,
/// The wire's `randomPropertyId` — the drop's **random-suffix roll**, the id the tooltip
/// resolves against [`super::Model::random_properties`] for its enchant lines (decision 1547).
/// `0` = unrolled.
///
/// The client keeps exactly this, at `+0x14` of its own 0x1c-byte loot record, and
/// `SetLootItem 0x533470` copies it into the tooltip's `+0x424` — a loot slot is **not** an
/// item object (that leg passes an all-zero item guid), so the roll is the only enchant source
/// a loot hover can have (wow-re `loot-slot-record.md`, `tooltip-content-law.md` §E6-LOOT).
/// [`Self::name`] already carries the suffix the same id joins on.
pub random_property_id: u32,
}
/// One open loot window: its rows (coin first when present). Pushed whole by the app; `None` means no
/// loot is open (the window is closed).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct LootState {
pub rows: Vec<LootRow>,
/// The window's **fixed** slot list: positions never shift while the window is open. A looted
/// slot becomes `None` — still counted by `GetNumLootItems` (the reference's slot array keeps
/// cleared slots; `LootFrame.numLootItems` is captured once at `OnShow`, l.132) but neither
/// `LootSlotIsItem` nor `LootSlotIsCoin`, which is exactly how the reference's
/// `LootFrame_Update` leaves a cleared slot's button hidden in place (`LootFrame.lua:80`)
/// instead of collapsing the rows below it upward.
pub rows: Vec<Option<LootRow>>,
/// Whether this loot came from fishing (`IsFishingLoot()` — the wire `SMSG_LOOT_RESPONSE`
/// `loot_type == 3`, decision 1086). `LootFrame_OnShow` keys the "FISHING REEL IN" sound and
/// the FishingLoot portrait overlay on it (`LootFrame.lua:137-140`).
@@ -94,7 +110,9 @@ impl super::UiScript {
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// GetNumLootItems() → the number of rows the open loot has (0 when none is open).
// GetNumLootItems() → the number of slots the open loot has, cleared ones included (0 when none
// is open). Constant while a window is open — the reference reads it once at OnShow (l.132) and
// never again; ours is invariant by construction, so the live read is the same value.
g.set(
"GetNumLootItems",
lua.create_function(|lua, ()| {
@@ -104,8 +122,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// GetLootSlotInfo(slot) → texture, item, quantity, quality (the Era flat-tuple shape,
// `LootFrame.lua:81`). `slot` is 1-based; out of range → nil. `item`/`quality` are nil while the
// item-template query is in flight; `texture` rides the wire display id, so it's there at once.
// `LootFrame.lua:81`). `slot` is 1-based; out of range → nil, and so is a cleared (already
// looted) slot. `item`/`quality` are nil while the item-template query is in flight; `texture`
// rides the wire display id, so it's there at once.
g.set(
"GetLootSlotInfo",
lua.create_function(|lua, slot: usize| {
@@ -115,7 +134,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.loot
.as_ref()
.and_then(|l| slot.checked_sub(1).and_then(|n| l.rows.get(n)))
.cloned()
.and_then(|r| r.clone())
};
let Some(row) = row else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
@@ -159,7 +178,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.loot
.as_ref()
.and_then(|l| slot.checked_sub(1).and_then(|n| l.rows.get(n)))
.and_then(|r| r.link.clone())
.and_then(|r| r.as_ref()?.link.clone())
};
match link {
Some(link) => Ok(Value::String(lua.create_string(&link)?)),
@@ -168,26 +187,27 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// LootSlotIsItem(slot) → true for a real item row (in range, not the coin pile).
// LootSlotIsItem(slot) → true for a real item row (in range, not the coin pile, not cleared).
g.set(
"LootSlotIsItem",
lua.create_function(|lua, slot: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(slot
.checked_sub(1)
.and_then(|n| model.loot.as_ref()?.rows.get(n))
.and_then(|n| model.loot.as_ref()?.rows.get(n)?.as_ref())
.is_some_and(|r| !r.is_coin))
})?,
)?;
// LootSlotIsCoin(slot) → true for the synthesized coin pile row.
// LootSlotIsCoin(slot) → true for the synthesized coin pile row (false once it's been looted —
// the cleared slot stays in the count but answers neither predicate, `LootFrame.lua:80`).
g.set(
"LootSlotIsCoin",
lua.create_function(|lua, slot: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(slot
.checked_sub(1)
.and_then(|n| model.loot.as_ref()?.rows.get(n))
.and_then(|n| model.loot.as_ref()?.rows.get(n)?.as_ref())
.is_some_and(|r| r.is_coin))
})?,
)?;
@@ -237,7 +257,7 @@ mod tests {
LootState {
rows: vec![
// The coin pile, always first. No link: there is no item to link (decision 1059).
LootRow {
Some(LootRow {
item_id: 0,
name: Some("1g 23s 45c".into()),
texture: Some("Interface\\Icons\\INV_Misc_Coin_01".into()),
@@ -245,9 +265,10 @@ mod tests {
quality: Some(1),
is_coin: true,
link: None,
},
random_property_id: 0,
}),
// A resolved item — name, quality AND link all landed together (one template answer).
LootRow {
Some(LootRow {
item_id: 0,
name: Some("Wool Cloth".into()),
texture: Some("Interface\\Icons\\INV_Fabric_Wool_01".into()),
@@ -255,9 +276,10 @@ mod tests {
quality: Some(1),
is_coin: false,
link: Some("|cffffffff|Hitem:2589:0:0:0|h[Wool Cloth]|h|r".into()),
},
random_property_id: 0,
}),
// An in-flight item: the loot arrived, the item-template answer hasn't.
LootRow {
Some(LootRow {
item_id: 0,
name: None,
texture: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
@@ -265,7 +287,8 @@ mod tests {
quality: None,
is_coin: false,
link: None,
},
random_property_id: 0,
}),
],
fishing: false,
}
@@ -330,6 +353,21 @@ mod tests {
assert!(s.eval::<bool>("return GetLootSlotLink(9) == nil").unwrap());
assert!(s.eval::<bool>("return not LootSlotIsItem(9)").unwrap());
assert!(s.eval::<bool>("return not LootSlotIsCoin(9)").unwrap());
// A CLEARED slot (looted): still counted — the slot list is fixed while the window is open,
// the reference's own shape (`LootFrame.numLootItems` is read once at OnShow) — but it
// answers neither predicate and nil info/link, so the frame hides that button in place
// instead of collapsing the rows below it.
let mut cleared = loot();
cleared.rows[0] = None;
s.set_loot(Some(cleared));
assert_eq!(s.eval::<i64>("return GetNumLootItems()").unwrap(), 3);
assert!(s.eval::<bool>("return GetLootSlotInfo(1) == nil").unwrap());
assert!(s.eval::<bool>("return GetLootSlotLink(1) == nil").unwrap());
assert!(s.eval::<bool>("return not LootSlotIsItem(1)").unwrap());
assert!(s.eval::<bool>("return not LootSlotIsCoin(1)").unwrap());
// …and the rows below keep their own slots.
assert!(s.eval::<bool>("return LootSlotIsItem(2)").unwrap());
}
#[test]
@@ -67,6 +67,13 @@ pub struct LootRollEntry {
/// popup shows the instant `START_LOOT_ROLL` fires, so this nil is the common case for the first
/// frames of every roll — both click arms drop it rather than posting an empty link.
pub link: Option<String>,
/// `SMSG_LOOT_START_ROLL`'s `randomPropertyId` — the drop's **random-suffix roll**, which the
/// hover resolves against [`super::Model::random_properties`] for its enchant lines. `0` =
/// unrolled. The reference's `SetLootRollItem 0x5364a0` copies the same value into the
/// tooltip's `+0x424` and passes no item object, so the roll is the roll window's only enchant
/// source — the loot window's own shape (decision 1547). [`Self::name`] carries the suffix the
/// same id joins on.
pub random_property_id: u32,
}
/// Every group loot roll currently open, in the order the app opened them. Pushed whole each frame.
@@ -221,6 +228,7 @@ mod tests {
time_left_ms: 42_000,
item_id: 17182,
link: Some("|cffa335ee|Hitem:17182:0:0:0|h[Staff of Jordan]|h|r".into()),
random_property_id: 0,
},
// An in-flight item: the roll opened, the item-template answer hasn't landed.
LootRollEntry {
@@ -233,6 +241,7 @@ mod tests {
time_left_ms: 60_000,
item_id: 4306,
link: None,
random_property_id: 0,
},
],
}
+226 -19
View File
@@ -70,13 +70,27 @@ pub struct MailInboxRow {
/// The stationery texture basename (`GetInboxText`'s second return) — the Lua wraps it as
/// `Interface\Stationery\<basename>1`/`2` for the open-letter backdrop.
pub stationery_texture: String,
/// `message_type == AUCTION(2)` — `GetInboxText`'s `isInvoice`.
/// `GetInboxText`'s fourth return. **Narrower than "an auction mail"**: the reference answers
/// `1` iff the record carries the three fields its subject parser persisted (`[rec+0x250] != 0`,
/// `0x4af2eb`), and the parser persists them *only* for result code 1 (won) or 2 (sold). An
/// outbid or expiry notice is an auction mail that is **not** an invoice (decision 1527).
pub is_invoice: bool,
/// The auction invoice this mail carries, once it can be answered — `GetInboxInvoiceInfo`'s
/// seven values. `None` is the reference's **miss tail**, and it covers four different states
/// that all read the same from Lua: not an auction mail at all; an auction mail whose result
/// code is not won(1)/sold(2) (an outbid or expiry notice carries no body and no invoice); the
/// body not fetched yet; or the counterparty's name still in flight.
pub invoice: Option<MailInvoice>,
/// The mail carries a fetchable letter body (`item_text_id != 0`) — gates the body ask.
pub has_body: bool,
/// The enclosed item's template entry (`0` = none) — the shared item-tooltip store's key
/// (`GameTooltip:SetInboxItem`) and `GetInboxItem`'s identity.
pub item_id: u32,
/// The enclosed item's **random-suffix roll** (`SMSG_MAIL_LIST`'s per-item `randomPropId`) —
/// what the hover resolves its enchant lines from, and the id whose suffix
/// [`Self::item_name`] already carries. `0` = unrolled. The reference's `SetInboxItem` writes
/// exactly this into the tooltip's `+0x424` (decision 1547).
pub item_random_property_id: u32,
/// The enclosed item's name (`GetInboxItem`'s first return); `None` while the template is in
/// flight.
pub item_name: Option<String>,
@@ -89,6 +103,29 @@ pub struct MailInboxRow {
pub can_delete: bool,
}
/// One auction mail's invoice — what `GetInboxInvoiceInfo(index)` hands back, already parsed.
///
/// The invoice is **TEXT**, not wire data: the auction house writes the numbers into the mail's
/// subject and body and the client `sscanf`s them back out (wow-re `ui/scratch/auction-house.md`
/// §11.1a). The app owns that parse; this is its result.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MailInvoice {
/// `true` → the literal token `"seller"` (your auction sold), `false` → `"buyer"` (you won).
/// A bare ASCII token in the reference too, not a GlobalString — the Lua compares it as one.
pub seller: bool,
/// The auctioned item's name, composed from the subject's item entry.
pub item_name: String,
/// The counterparty: who bought it (seller invoice) or who sold it (buyer invoice).
pub player_name: String,
/// The winning bid. `bid == buyout` is how the window knows it was a buyout rather than a bid.
pub bid: u32,
pub buyout: u32,
/// Seller invoices only (`0` on a buyer invoice — the body carries three fields, not five).
pub deposit: u32,
/// The auction house's cut, seller invoices only.
pub consignment: u32,
}
/// The open mailbox's inbox snapshot. Pushed whole by the app; `None` means no mailbox is open (the
/// window is closed).
#[derive(Clone, Debug, Default, PartialEq)]
@@ -209,19 +246,6 @@ fn flag(b: bool) -> Value {
}
}
/// An enclosed item's `canUse`, the shared item-usable gate over the row's template from the
/// merchant/tooltip store, keyed by the item id — usable while the template is still in flight (the
/// null-record skip). Spell knowledge comes from the engine's spellbook mirror, as the tooltip's
/// "Requires <spell>" red does.
fn is_usable(model: &Model, item_id: u32) -> bool {
item_id == 0
|| model.item_templates.get(&item_id).is_none_or(|v| {
super::item_stats::item_usable(v, &model.player_req, |id| {
model.spellbook.slots.iter().any(|s| s.spell_id == id)
})
})
}
/// Fetch a cloned inbox row for a 1-based index, or `None` (out of range / no mailbox open).
fn row_at(model: &Model, index: usize) -> Option<MailInboxRow> {
model
@@ -316,7 +340,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(r.body.as_deref().unwrap_or(""))?),
// **An invoice's body is `nil`, and that is an explicit carve-out in the reference,
// not an accident** (`0x4af1cf cmp [esi+0x4],2` / `je` → `lua_pushnil`, decision
// 1527). The auction house's raw `<guid>:<n>:<n>:…` bookkeeping really does sit in
// the shared text cache — `GetInboxInvoiceInfo` `sscanf`s exactly that string — but
// this binding refuses to hand it back. Two bindings, one cache, opposite purposes.
// It is what lets MailFrame lay the invoice pane over the letter page without the
// bookkeeping showing through above it.
if r.is_invoice {
Value::Nil
} else {
Value::String(lua.create_string(r.body.as_deref().unwrap_or(""))?)
},
Value::String(lua.create_string(&r.stationery_texture)?),
flag(r.has_body),
flag(r.is_invoice),
@@ -324,6 +359,46 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// GetInboxInvoiceInfo(index) → invoiceType, itemName, playerName, bid, buyout, deposit,
// consignment (MailFrame.lua l.302). **SEVEN values, always** — the reference's own
// `mov eax,7`, with the miss tail `nil, nil, nil, 0, 0, 0, 0`: three nils then four zeros
// (wow-re `ui/scratch/auction-house.md` §11.1a). MailFrame.lua guards on the third
// (`if playerName then`), so the shape of the miss is what keeps the invoice pane hidden —
// returning one bare nil instead would leave the four numeric destructures nil and the pane's
// arithmetic would run on them.
g.set(
"GetInboxInvoiceInfo",
lua.create_function(|lua, index: usize| {
let row = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
row_at(&model, index)
};
let miss = || {
MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Nil,
Value::Integer(0),
Value::Integer(0),
Value::Integer(0),
Value::Integer(0),
])
};
let Some(inv) = row.and_then(|r| r.invoice) else {
return Ok(miss());
};
Ok(MultiValue::from_vec(vec![
Value::String(lua.create_string(if inv.seller { "seller" } else { "buyer" })?),
Value::String(lua.create_string(&inv.item_name)?),
Value::String(lua.create_string(&inv.player_name)?),
Value::Integer(i64::from(inv.bid)),
Value::Integer(i64::from(inv.buyout)),
Value::Integer(i64::from(inv.deposit)),
Value::Integer(i64::from(inv.consignment)),
]))
})?,
)?;
// GetInboxItem(index) → name, texture, count, quality, canUse (MailFrame.lua l.379). name/quality
// are nil while the item-template answer is in flight; a mail with no item answers all-nil name.
// canUse is the shared item-usable gate over the enclosed item's template (merchant's isUsable
@@ -334,7 +409,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let (row, usable) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let row = row_at(&model, index);
let usable = row.as_ref().is_none_or(|r| is_usable(&model, r.item_id));
let usable = row
.as_ref()
.is_none_or(|r| super::item_stats::item_usable_by_id(&model, r.item_id));
(row, usable)
};
let Some(r) = row.filter(|r| r.item_id != 0) else {
@@ -466,11 +543,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"GetSendMailItem",
lua.create_function(|lua, ()| {
// The stack count is read while the model is borrowed: a whole-stack pickup records
// no count of its own, so the true size comes back from the source slot.
let item = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.mail_send_item.clone()
model.mail_send_item.clone().map(|it| {
let count = cursor::held_count(&model, &it);
(it, count)
})
};
let Some(it) = item else {
let Some((it, count)) = item else {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
@@ -495,7 +577,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(MultiValue::from_vec(vec![
name,
texture,
Value::Integer(i64::from(it.count.unwrap_or(1).max(1))),
Value::Integer(i64::from(count)),
quality,
]))
})?,
@@ -576,6 +658,7 @@ mod tests {
body: Some("Lok'tar.".into()),
stationery_texture: "STATIONERYTEST".into(),
is_invoice: false,
invoice: None,
has_body: true,
item_id,
item_name: (item_id != 0).then(|| "Linen Cloth".to_string()),
@@ -583,6 +666,7 @@ mod tests {
.then(|| "Interface\\Icons\\INV_Fabric_Linen_01".to_string()),
item_quality: (item_id != 0).then_some(1),
can_delete: false, // from a player, not returned → returnable (not deletable)
item_random_property_id: 0,
}
}
@@ -592,6 +676,129 @@ mod tests {
}
}
/// `GetInboxInvoiceInfo` answers **seven values, always** — the reference's own `mov eax,7`.
/// The miss tail is the part that matters and the part that is easy to get wrong: three nils
/// then four ZEROS, not one bare nil. MailFrame.lua destructures all seven unguarded and only
/// then tests the third, so a short return leaves its arithmetic running on nils.
#[test]
fn the_invoice_answers_seven_values_or_a_three_nil_four_zero_miss() {
let mut s = UiScript::new().unwrap();
let mut st = state();
st.inbox[0].is_invoice = true;
st.inbox[0].invoice = Some(MailInvoice {
seller: true,
item_name: "Linen Cloth".into(),
player_name: "Twowarrior".into(),
bid: 10_000,
buyout: 10_000,
deposit: 25,
consignment: 500,
});
s.set_mail(Some(st));
assert_eq!(
s.eval::<usize>("return select('#', GetInboxInvoiceInfo(1))")
.unwrap(),
7
);
let vals: Vec<String> = (1..=7)
.map(|i| {
s.eval::<String>(&format!(
"return tostring((select({i}, GetInboxInvoiceInfo(1))))"
))
.unwrap()
})
.collect();
assert_eq!(
vals,
[
"seller",
"Linen Cloth",
"Twowarrior",
"10000",
"10000",
"25",
"500"
],
"a seller invoice, in the reference's own order"
);
// Row 2 carries no invoice — and neither does an index off the end.
for idx in [2, 99] {
assert_eq!(
s.eval::<usize>(&format!("return select('#', GetInboxInvoiceInfo({idx}))"))
.unwrap(),
7,
"the miss is still seven values"
);
let tail: Vec<String> = (1..=7)
.map(|i| {
s.eval::<String>(&format!(
"return tostring((select({i}, GetInboxInvoiceInfo({idx}))))"
))
.unwrap()
})
.collect();
assert_eq!(tail, ["nil", "nil", "nil", "0", "0", "0", "0"]);
}
}
/// `GetInboxText` returns **four** values always, and for an INVOICE the first is `nil`.
/// That is the reference's own carve-out (`0x4af1cf cmp [esi+0x4],2` → `lua_pushnil`), and it
/// is what lets MailFrame lay the invoice pane over the letter page: the auction house's raw
/// bookkeeping is in the text cache — `GetInboxInvoiceInfo` parses exactly that string — and
/// this binding refuses to hand it back (decision 1527).
#[test]
fn an_invoice_has_no_letter_body() {
let mut s = UiScript::new().unwrap();
let mut st = state();
st.inbox[0].is_invoice = true;
st.inbox[0].body = Some("6C:10000:10000:25:500".into());
s.set_mail(Some(st));
assert_eq!(
s.eval::<usize>("return select('#', GetInboxText(1))")
.unwrap(),
4,
"four values, invoice or not"
);
assert_eq!(
s.eval::<String>("return tostring((GetInboxText(1)))")
.unwrap(),
"nil",
"the bookkeeping is never handed back as a letter body"
);
// Row 2 is an ordinary letter and keeps its text.
assert_eq!(
s.eval::<String>("return tostring((GetInboxText(2)))")
.unwrap(),
"Lok'tar."
);
}
/// A BUYER invoice says so with the bare ASCII token the Lua compares against — not a
/// GlobalString, not localized.
#[test]
fn a_buyer_invoice_is_the_literal_token_buyer() {
let mut s = UiScript::new().unwrap();
let mut st = state();
st.inbox[0].is_invoice = true;
st.inbox[0].invoice = Some(MailInvoice {
seller: false,
item_name: "Small Blue Pouch".into(),
player_name: "Onewarrior".into(),
bid: 9_000,
buyout: 10_000,
deposit: 0,
consignment: 0,
});
s.set_mail(Some(st));
assert_eq!(
s.eval::<String>("return (GetInboxInvoiceInfo(1))").unwrap(),
"buyer"
);
}
#[test]
fn inbox_header_reads_the_reference_tuple() {
let mut s = UiScript::new().unwrap();
+6 -14
View File
@@ -149,18 +149,6 @@ impl super::UiScript {
}
}
/// A row's `isUsable`, the real getters' exact read: the item-usable gate over the row's
/// template from the shared store, keyed by the row's item id — usable while the template is
/// still in flight (the null-record skip, `0x4fb298`). Spell knowledge comes from the engine's
/// spellbook mirror, the same source the tooltip's "Requires <spell>" red reads.
fn is_usable(model: &Model, item_id: u32) -> bool {
model.item_templates.get(&item_id).is_none_or(|v| {
super::item_stats::item_usable(v, &model.player_req, |id| {
model.spellbook.slots.iter().any(|s| s.spell_id == id)
})
})
}
/// `1`/`nil` — how the client pushes a usable flag (`pushnumber(1.0)` / `pushnil`).
fn usable_value(usable: bool) -> Value {
if usable {
@@ -209,7 +197,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.as_ref()
.and_then(|m| index.checked_sub(1).and_then(|n| m.items.get(n)))
.cloned();
let usable = item.as_ref().is_none_or(|it| is_usable(&model, it.item_id));
let usable = item
.as_ref()
.is_none_or(|it| super::item_stats::item_usable_by_id(&model, it.item_id));
(item, usable)
};
let Some(it) = item else {
@@ -337,7 +327,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
.as_ref()
.and_then(|m| index.checked_sub(1).and_then(|n| m.buyback.get(n)))
.cloned();
let usable = item.as_ref().is_none_or(|it| is_usable(&model, it.item_id));
let usable = item
.as_ref()
.is_none_or(|it| super::item_stats::item_usable_by_id(&model, it.item_id));
(item, usable)
};
let Some(it) = item else {
+10 -2
View File
@@ -39,6 +39,7 @@
mod action;
mod action_bar_toggles;
mod addon_message;
mod auction;
mod aura;
mod backdrop;
mod bank;
@@ -138,6 +139,10 @@ mod worn_display;
pub use action::{ActionSlot, ActionState};
pub use addon::AddOnInfo;
pub use addon_message::{AddonDistribution, AddonSend};
pub use auction::{
AuctionBid, AuctionCategory, AuctionItemRow, AuctionListState, AuctionQuery,
AuctionStartRequest, AuctionState, AuctionSubCategory, BIDDER, LIST, OWNER, SORT_KEYS,
};
pub use aura::{AuraState, TrackingState};
pub use backdrop::{inset_atlas_bleed, pieces, Backdrop, BackdropPiece, Insets};
pub use bank::BankState;
@@ -146,7 +151,9 @@ pub use char_stats::{
SKILL_DEFENSE, SKILL_UNARMED,
};
pub use chat_send::ChatSend;
pub use container::{ContainerMove, ContainerSlot, ContainerState, EnchantView, UiCursorMode};
pub use container::{
ContainerMove, ContainerSlot, ContainerState, EnchantView, RandomPropertyView, UiCursorMode,
};
pub use craft::{CraftReagent, CraftRecipe, CraftState, CraftTooltip};
pub use cursor::{
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorPetAction, CursorSpell,
@@ -167,7 +174,7 @@ pub use item_text::ItemTextState;
pub use loot::{LootRow, LootState};
pub use loot_roll::{LootRollEntry, LootRollsState};
pub use macros::{MacroState, MacroView, MAX_MACROS, MAX_MACRO_BODY, MAX_MACRO_NAME};
pub use mail::{MailInboxRow, MailSendRequest, MailState};
pub use mail::{MailInboxRow, MailInvoice, MailSendRequest, MailState};
pub use measure::TextMeasure;
pub use merchant::{ItemStatsHead, MerchantItem, MerchantState};
pub(crate) use model::Model;
@@ -555,6 +562,7 @@ impl UiScript {
bank::install(&lua)?;
item_text::install(&lua)?;
mail::install(&lua)?;
auction::install(&lua)?;
trainer::install(&lua)?;
taxi::install(&lua)?;
trade::install(&lua)?;
+50 -5
View File
@@ -4,11 +4,11 @@ use crate::layout::{LayoutInput, LayoutSolver, Rect};
use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
use super::{
backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip, guild,
inspect, item_text, loot, loot_roll, macros, mail, merchant, party, pvp, quest, quest_log,
reputation, session, simplehtml, skills, slider, social, spellbook, taxi, trade, tradeskill,
trainer, weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView, PlayerReqState,
RegionData, ScriptValue, SoundRequest, UnitState,
auction, backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip,
guild, inspect, item_text, loot, loot_roll, macros, mail, merchant, party, pvp, quest,
quest_log, reputation, session, simplehtml, skills, slider, social, spellbook, taxi, trade,
tradeskill, trainer, weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView,
PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -931,6 +931,30 @@ pub(crate) struct Model {
/// (`MiniMapMailFrame`) reads this on `UPDATE_PENDING_MAIL`.
pub(crate) has_new_mail: bool,
/// The open auction house's snapshot the app pushes (`None` = no auctioneer session) and the
/// intents the app drains — the auction seam ([`auction`], decision 1511). `auction_selected`
/// is engine-local per list ([`auction::LIST`]/`BIDDER`/`OWNER`) and holds the selected
/// **auction id**, not a row position (wow-re §5 TU-5): an id follows its row through a
/// re-sort, where an index would quietly come to mean the auction that took its place. `0` =
/// nothing selected. It is engine-local because the reference reads the selection back
/// synchronously inside the same handler that sets it, so it cannot be a round trip. `auction_can_query` is the app's 5 s browse
/// throttle, pushed separately from the snapshot because the Search button polls it every
/// frame and it would otherwise churn the snapshot's diff.
pub(crate) auction: Option<auction::AuctionState>,
pub(crate) auction_selected: [u32; 3],
pub(crate) auction_can_query: bool,
pub(crate) auction_query: Option<auction::AuctionQuery>,
pub(crate) auction_owner_query: Option<u32>,
pub(crate) auction_bidder_query: Option<u32>,
pub(crate) auction_bids: Vec<auction::AuctionBid>,
pub(crate) auction_cancels: Vec<u32>,
pub(crate) auction_start: Option<auction::AuctionStartRequest>,
pub(crate) auction_sorts: Vec<(usize, String)>,
pub(crate) auction_close: bool,
/// The sell slot's staged item (a cursor drop, decision 0216) — carried until `StartAuction`
/// fires, when the app resolves its `(bag, slot)` to the wire item guid.
pub(crate) auction_sell_item: Option<cursor::CursorItem>,
/// The open trade window's snapshot the app pushes (`None` = no trade open) and the intents the
/// app drains — the trade seam ([`trade`], decision 0592 P1). `trade_initiates` are the unit
/// tokens `InitiateTrade` queued (the app resolves each → guid → `CMSG_INITIATE_TRADE`); the
@@ -977,6 +1001,14 @@ pub(crate) struct Model {
/// push/ask flow as the templates ([`item_stats`] module doc).
pub(crate) item_sets: HashMap<u32, super::ItemSetView>,
pub(crate) item_set_asks: HashSet<u32>,
/// The **random-suffix roll** store (`ItemRandomProperties` id → its resolved view) — pushed
/// WHOLE at startup, not asked for (decision 1547). It is a static DBC the app has in memory
/// from load, and its consumers are click-driven (a chat-link tooltip has no hover re-enter
/// loop to repaint on a late answer), so the ask-once shape the template store uses would
/// leave a first click showing an item with no lines. This mirrors the reference exactly: the
/// builder resolves its `+0x424` against the loaded DBC store `0xc0dbd4` at draw time, and
/// every source supplies only the id (wow-re `tooltip-content-law.md` §1-ENCHANT §E5).
pub(crate) random_properties: HashMap<u32, super::RandomPropertyView>,
/// The red-line law's player state (level/class/race/skills — [`item_stats`] module doc).
pub(crate) player_req: PlayerReqState,
/// The spell-tooltip store: `spell id → resolved view` ([`tooltip_spell`] module doc,
@@ -1462,6 +1494,18 @@ impl Model {
mail_send_cod: 0,
mail_send_item: None,
has_new_mail: false,
auction: None,
auction_selected: [0; 3],
auction_can_query: true,
auction_query: None,
auction_owner_query: None,
auction_bidder_query: None,
auction_bids: Vec::new(),
auction_cancels: Vec::new(),
auction_start: None,
auction_sorts: Vec::new(),
auction_close: false,
auction_sell_item: None,
trade: None,
trade_initiates: Vec::new(),
trade_accept: false,
@@ -1482,6 +1526,7 @@ impl Model {
item_templates: HashMap::new(),
item_sets: HashMap::new(),
item_set_asks: HashSet::new(),
random_properties: HashMap::new(),
item_stat_asks: HashSet::new(),
player_req: PlayerReqState::default(),
spell_tooltips: HashMap::new(),
@@ -615,6 +615,204 @@ fn temporary_enchant_line_carries_its_countdown_and_charges() {
);
}
/// **The loot hover shows the ROLL, never the placeholder** (decision 1547; wow-re §E6-LOOT).
/// `SetLootItem 0x533470` writes an instance block (p6=1) whose `+0x424` is the wire's
/// randomPropertyId and whose seven enchant slots are zeroed, and passes an all-zero item GUID —
/// so there is never an object, the builder takes §E5's suffix-row copy into slots 2..6, and the
/// `ITEM_RANDOM_ENCHANT` arm is unreachable (§E1's fork). The row's name is the suffix-joined one
/// (`0x5d8b00`, the same string `GetLootSlotInfo` returns), so the plate reads "… of the Monkey".
///
/// This is the reported bug's exact shape: through the template path (`SetItemById`) the same
/// hover printed `<Random enchantment>` until the item reached a bag.
#[test]
fn a_looted_roll_shows_its_lines_and_never_the_placeholder() {
let mut s = script();
s.set_item_template(
8888,
ItemTemplateView {
name: "Bloodrazor".into(),
quality: 2,
class: 2,
// The template CAN roll — the placeholder's own gate, so its absence below is the law
// and not an accident of the fixture.
random_property: 42,
..Default::default()
},
);
s.set_loot(Some(LootState {
rows: vec![Some(LootRow {
name: Some("Bloodrazor of the Monkey".into()),
texture: None,
quantity: 1,
quality: Some(2),
is_coin: false,
item_id: 8888,
link: Some("|cff1eff00|Hitem:8888:0:584:0|h[Bloodrazor of the Monkey]|h|r".into()),
// The row carries the ROLL, not resolved lines — the engine resolves it, like the
// reference reading its own DBC store off the block's `+0x424`.
random_property_id: 584,
})],
fishing: false,
}));
s.set_random_properties(
[(
584,
RandomPropertyView {
suffix: "of the Monkey".into(),
enchants: vec![
EnchantView {
slot: 2,
name: "Agility +7".into(),
..Default::default()
},
EnchantView {
slot: 3,
name: "Stamina +7".into(),
..Default::default()
},
],
},
)]
.into_iter()
.collect(),
);
s.run(
r#"
local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(Slot, "ANCHOR_RIGHT")
tt:SetLootItem(1)
"#,
)
.unwrap();
let lines = super::lines_of(&mut s);
let texts: Vec<&str> = lines.iter().map(|(t, _)| t.as_str()).collect();
assert!(
!texts.contains(&"<Random enchantment>"),
"a block source never reaches the placeholder arm — got {texts:?}"
);
assert_eq!(
texts.first(),
Some(&"Bloodrazor of the Monkey"),
"the plate takes the roll's suffixed name — got {texts:?}"
);
for line in ["Agility +7", "Stamina +7"] {
let l = lines
.iter()
.find(|(t, _)| t == line)
.unwrap_or_else(|| panic!("the roll's slot line {line} — got {texts:?}"));
// Slots 2..6 are always WHITE, whatever the sign (§E3).
assert_eq!(l.1, [1.0, 1.0, 1.0, 1.0], "{line} is white");
}
}
/// The other half of the same law: a loot row with **no** roll shows no enchant line AND no
/// placeholder, even though the template can roll. The fork tests the block's presence, not its
/// contents (§E1) — which is why this cannot be expressed as "print the placeholder when the
/// slots are empty".
#[test]
fn a_looted_item_with_no_roll_shows_neither_line_nor_placeholder() {
let mut s = script();
s.set_item_template(
8888,
ItemTemplateView {
name: "Bloodrazor".into(),
quality: 2,
class: 2,
random_property: 42,
..Default::default()
},
);
s.set_loot(Some(LootState {
rows: vec![Some(LootRow {
name: Some("Bloodrazor".into()),
quantity: 1,
quality: Some(2),
item_id: 8888,
..Default::default()
})],
fishing: false,
}));
s.run(
r#"
local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(Slot, "ANCHOR_RIGHT")
tt:SetLootItem(1)
"#,
)
.unwrap();
let texts: Vec<String> = super::lines_of(&mut s)
.into_iter()
.map(|(t, _)| t)
.collect();
assert!(
!texts.iter().any(|t| t == "<Random enchantment>"),
"no placeholder on a block source — got {texts:?}"
);
}
/// A **chat link** is a block source too (`SetHyperlink 0x532181`, p6=1): its `|Hitem:` token 2 is
/// the roll, which §E5 expands into slots 2..6, and the placeholder arm is unreachable. Decision
/// 0920's prose said a hyperlink hover shows the placeholder; §E1's fork says otherwise, and this
/// is the case that proves it — the same drop, linked in chat instead of hovered in the loot
/// window, must read identically.
#[test]
fn a_linked_roll_shows_its_lines_and_never_the_placeholder() {
let mut s = script();
s.set_item_template(
8888,
ItemTemplateView {
name: "Bloodrazor".into(),
quality: 2,
class: 2,
random_property: 42,
..Default::default()
},
);
s.set_random_properties(
[(
584,
RandomPropertyView {
suffix: "of the Monkey".into(),
enchants: vec![EnchantView {
slot: 2,
name: "Agility +7".into(),
..Default::default()
}],
},
)]
.into_iter()
.collect(),
);
s.run(
r#"
local a = CreateFrame("Button", "Slot"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(Slot, "ANCHOR_RIGHT")
tt:SetHyperlink("|cff1eff00|Hitem:8888:0:584:0|h[Bloodrazor of the Monkey]|h|r")
"#,
)
.unwrap();
let texts: Vec<String> = super::lines_of(&mut s)
.into_iter()
.map(|(t, _)| t)
.collect();
assert!(
!texts.iter().any(|t| t == "<Random enchantment>"),
"a link never reaches the placeholder arm — got {texts:?}"
);
assert_eq!(
texts.first().map(String::as_str),
Some("Bloodrazor of the Monkey"),
"the plate takes the link's own suffixed name — got {texts:?}"
);
assert!(
texts.iter().any(|t| t == "Agility +7"),
"the roll's slot line — got {texts:?}"
);
}
/// `<Random enchantment>` (§E5) — the template-only placeholder: a random-property item with NO
/// instance to read a roll from prints it, green, and the per-slot lines and this one are mutually
/// exclusive by construction. The control is the same template hovered as a real enchanted
+293 -14
View File
@@ -56,6 +56,19 @@ fn view_of(lua: &Lua, item_id: u32) -> Option<ItemTemplateView> {
v
}
/// The enchant lines a **random-suffix roll** contributes, out of the pushed roll table — the
/// reference's §E5 copy of the suffix row's five ids into enchant slots 2..6, which every
/// block-supplying source with no item object relies on (loot, links, auction rows, the roll
/// window). `0`, or an id the table doesn't name, contributes nothing.
fn roll_enchants(lua: &Lua, random_property_id: u32) -> Vec<crate::script::EnchantView> {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.random_properties
.get(&random_property_id)
.map(|v| v.enchants.clone())
.unwrap_or_default()
}
/// The bracketed name out of an `|Hitem:…|h[Name]|h` link (the container slot's own display
/// name — the miss-path fallback line's source).
fn link_name(link: &str) -> Option<&str> {
@@ -84,15 +97,25 @@ fn fire_add_money(lua: &Lua, h: crate::widget::FrameHandle, copper: u64) {
}
}
/// The item id inside a hyperlink — the full escaped shape (`|cff…|Hitem:2947:0:0:0|h[Name]|h|r`)
/// or a bare `item:2947`.
fn hyperlink_item_id(link: &str) -> Option<u32> {
/// The `|Hitem:` payload's numeric fields — the full escaped shape
/// (`|cff…|Hitem:2947:0:584:0|h[Name]|h|r`) or a bare `item:2947`.
///
/// The four are `(item id, enchant id, randomPropertyId, uniqueId)` — the reference's own link
/// format `"%s|Hitem:%d:%d:%d:%d|h[%s]|h%s"` (`0x8549c8`), and `SetHyperlink 0x532181` parses them
/// straight into the tooltip's instance block: token 1 → enchant slot 0 (`+0x3d0`), **token 2 →
/// `+0x424`, the roll** (which §E5 then expands into slots 2..6), token 3 → `+0x420`, which
/// nothing reads. Missing fields read `0`, like the bare `item:id` shape an addon may pass.
fn hyperlink_item_fields(link: &str) -> Option<(u32, u32, u32)> {
let at = link.find("item:")? + 5;
let digits: String = link[at..]
.chars()
.take_while(char::is_ascii_digit)
.collect();
digits.parse().ok().filter(|&id| id != 0)
let tail = &link[at..];
let end = tail.find("|h").unwrap_or(tail.len());
let mut fields = tail[..end]
.split(':')
.map(|f| f.trim().parse().unwrap_or(0));
let item_id: u32 = fields.next()?;
let enchant_id = fields.next().unwrap_or(0);
let random_property_id = fields.next().unwrap_or(0);
(item_id != 0).then_some((item_id, enchant_id, random_property_id))
}
/// The shared id-keyed render (`SetItemById`/`SetHyperlink`): template hit → the full line law
@@ -269,10 +292,51 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
m.set(
"SetHyperlink",
lua.create_function(|lua, (this, link): (Table, String)| {
let Some(id) = hyperlink_item_id(&link) else {
let Some((id, _enchant_id, roll)) = hyperlink_item_fields(&link) else {
return Ok(());
};
render_by_id(lua, &this, id, link_name(&link).map(str::to_string), None)
let h = frame_handle_of(lua, &this)?;
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(lua, h);
// A link is a **block source** (`SetHyperlink 0x532181` passes p6=1), so it never
// reaches the `<Random enchantment>` arm and it does show the roll's own lines — the
// half of §E5 decision 0920's prose had backwards. The NAME comes off the link's own
// brackets: the sender's client built that text with the suffix already joined
// (`0x5d8b00` feeds the link builder), so re-deriving it would only invite the two to
// disagree.
//
// The link's ENCHANT field (slot 0, a permanent enchant like "Crusader") is parsed but
// not yet rendered: naming it needs the `SpellItemEnchantment` row, and no benilla
// surface writes that field into a link it composes today. A stated gap, not drift.
let inst = render::ItemInstance {
name: link_name(&link).map(str::to_string),
enchants: roll_enchants(lua, roll),
..Default::default()
};
match view_of(lua, id) {
Some(v) => {
render_view(lua, &this, &v, false, Some(&inst))?;
// Unchanged from the `render_by_id` this leg used to share: a link dropped on
// the MAIN tooltip still arms the shopping compare (`arm_compare`'s own gate).
arm_compare(lua, h, &v);
}
None => {
if let Some(name) = link_name(&link) {
append_line(
lua,
&this,
(name.to_string(), quality_color(1)),
None,
false,
)?;
}
}
}
show_or_hide_empty(lua, h);
Ok(())
})?,
)?;
@@ -305,6 +369,9 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
s.name.clone(),
s.quality,
render::ItemInstance {
// The slot's own name — app-composed, so it carries the
// random-suffix roll off `ITEM_FIELD_RANDOM_PROPERTIES_ID`.
name: s.name.clone(),
durability: s.durability,
creator: s.creator.clone(),
has_text: false,
@@ -387,6 +454,10 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
s.link.clone(),
s.quality.unwrap_or(1),
render::ItemInstance {
// The bag slot's display name rides its LINK (the reference
// builds that link out of `0x5d8b00`'s output, so the two are the
// same string by construction — the roll's suffix included).
name: s.link.as_deref().and_then(link_name).map(str::to_string),
durability: s.durability,
creator: s.creator.clone(),
has_text: s.readable,
@@ -448,6 +519,119 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// GameTooltip:SetLootItem(slot) — the loot-row hover (the reference's own binding,
// `0x533470`, which its `LootButtonTemplate` <OnEnter> runs behind `LootSlotIsItem`).
// **A block source, not a template one** (§1-SESSION's writer table: the leg at `0x533564`
// supplies the instance block, p6=1), and that is the whole point of it existing here: a loot
// slot is no item object, so its rolled random-suffix enchants reach the builder through the
// block — never through `ITEM_FIELD_ENCHANTMENT`, which the wire does not carry for loot.
// Hovering a "… of the Monkey" drop through the template path `SetItemById` instead printed
// the `<Random enchantment>` placeholder until the item was in the bag (decision 1547).
//
// `slot` is the 1-based display row, like every other loot getter; the coin pile and a
// cleared row have no tooltip.
m.set(
"SetLootItem",
lua.create_function(|lua, (this, slot): (Table, usize)| {
let h = frame_handle_of(lua, &this)?;
let row = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
model
.loot
.as_ref()
.and_then(|l| slot.checked_sub(1).and_then(|n| l.rows.get(n)))
.and_then(|r| r.clone())
.filter(|r| !r.is_coin && r.item_id != 0)
};
let Some(row) = row else {
return Ok(());
};
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(lua, h);
// The row's own name is the app-composed one — the roll's suffix already joined, so
// the plate reads "Chipped Claw of the Bear" like the reference's `0x5d8b00` output.
// The lines come from the roll id through the pushed table, which is where the
// reference reads them too (its `+0x424` against the DBC store, §E5).
let inst = render::ItemInstance {
name: row.name.clone(),
enchants: roll_enchants(lua, row.random_property_id),
..Default::default()
};
match view_of(lua, row.item_id) {
Some(v) => render_view(lua, &this, &v, false, Some(&inst))?,
// Template in flight — the row's name holds the plate (SetBagItem's 0138 posture;
// the re-enter loop repaints when the push lands).
None => {
if let Some(name) = row.name.clone() {
append_line(
lua,
&this,
(name, quality_color(row.quality.unwrap_or(1))),
None,
false,
)?;
}
}
}
show_or_hide_empty(lua, h);
Ok(())
})?,
)?;
// GameTooltip:SetLootRollItem(rollId) — the group-loot roll window's hover (the reference's
// own `0x5364a0`). The same shape as SetLootItem beside it, byte-verified on the same
// dispatch: p6=1 with `+0x424 ← [roll+0x20]` (the roll's randomPropertyId), all 7 enchant
// slots zero, and BOTH guid args `&{0,0}` — no item object, so §E5's suffix-row copy is the
// only enchant source and the placeholder arm is unreachable.
m.set(
"SetLootRollItem",
lua.create_function(|lua, (this, roll_id): (Table, u32)| {
let h = frame_handle_of(lua, &this)?;
let entry = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
model
.loot_rolls
.rolls
.iter()
.find(|r| r.roll_id == roll_id)
.cloned()
.filter(|r| r.item_id != 0)
};
let Some(entry) = entry else {
return Ok(());
};
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(lua, h);
let inst = render::ItemInstance {
name: entry.name.clone(),
enchants: roll_enchants(lua, entry.random_property_id),
..Default::default()
};
match view_of(lua, entry.item_id) {
Some(v) => render_view(lua, &this, &v, false, Some(&inst))?,
None => {
if let Some(name) = entry.name.clone() {
append_line(
lua,
&this,
(name, quality_color(entry.quality.unwrap_or(1))),
None,
false,
)?;
}
}
}
show_or_hide_empty(lua, h);
Ok(())
})?,
)?;
// 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
@@ -515,14 +699,109 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
"SetInboxItem",
lua.create_function(|lua, (this, index): (Table, usize)| {
let h = frame_handle_of(lua, &this)?;
let item_id = {
let (item_id, roll, name) = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(mail) = &model.mail else {
return Ok(());
};
mail.inbox
.get(index.saturating_sub(1))
.map(|r| r.item_id)
match mail.inbox.get(index.saturating_sub(1)) {
Some(r) => (r.item_id, r.item_random_property_id, r.item_name.clone()),
None => (0, 0, None),
}
};
if item_id == 0 {
return Ok(());
}
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(lua, h);
// A block source (`SetInboxItem 0x5355fa`, p6=1) carrying the attachment's roll — so
// an "… of the Monkey" in the mail reads like the same drop in the loot window, and
// the placeholder arm is unreachable here too (decision 1547).
let inst = render::ItemInstance {
name,
enchants: roll_enchants(lua, roll),
..Default::default()
};
if let Some(v) = view_of(lua, item_id) {
render_view(lua, &this, &v, false, Some(&inst))?;
arm_compare(lua, h, &v);
}
show_or_hide_empty(lua, h);
Ok(())
})?,
)?;
// GameTooltip:SetAuctionItem(type, index) — an auction ROW's hover (decision 1511). The row's
// item entry through the same id-keyed store, keyed by list type ("list"/"bidder"/"owner").
//
// An auction row's tooltip deliberately emits **no** "Made by", "Gift from" or openable lines,
// and the reason is structural rather than a rule anyone applies: the reference's binding
// zeroes the GUID arguments before it resolves anything, so the gate those lines hang off
// fails outright (wow-re §5, claim 6). Our id-keyed store has no instance to report either,
// so we land on the same output by the same shape of reasoning.
m.set(
"SetAuctionItem",
lua.create_function(|lua, (this, kind, index): (Table, String, usize)| {
let h = frame_handle_of(lua, &this)?;
let (item_id, roll, name) = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(auction) = &model.auction else {
return Ok(());
};
let Some(list) = super::auction::list_index_of(&kind) else {
return Ok(());
};
match auction.lists[list].rows.get(index.saturating_sub(1)) {
Some(r) => (r.item_id, r.random_property_id, r.name.clone()),
None => (0, 0, None),
}
};
if item_id == 0 {
return Ok(());
}
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(lua, h);
// A block source (`SetAuctionItem 0x5359d9`, p6=1) carrying the listing's roll: a
// rolled auction shows its real lines, never the placeholder (decision 1547). The
// zeroed GUIDs the note above describes are unaffected — a roll is not an instance.
let inst = render::ItemInstance {
name,
enchants: roll_enchants(lua, roll),
..Default::default()
};
if let Some(v) = view_of(lua, item_id) {
render_view(lua, &this, &v, false, Some(&inst))?;
arm_compare(lua, h, &v);
}
show_or_hide_empty(lua, h);
Ok(())
})?,
)?;
// GameTooltip:SetAuctionSellItem() — the create-auction slot's hover (decision 1511). The item
// staged in the sell slot, through the same id-keyed store. A no-op when the slot is empty
// (the reference gates the call on GetAuctionSellItemInfo()).
//
// Unlike an auction ROW, this one names a real item the player owns — the reference passes the
// live sell-slot GUID here and resolves an actual object, so its creator line is structurally
// reachable (wow-re §5, claim 6, REFINED). Ours is still the id-keyed template view, so we do
// not print one; that is a known narrowing, not a claim about the reference.
m.set(
"SetAuctionSellItem",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let item_id = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
model
.auction_sell_item
.as_ref()
.map(|it| it.item_id)
.unwrap_or(0)
};
if item_id == 0 {
@@ -15,6 +15,13 @@ use super::names::*;
/// object, no lines), and keeps the authored full max/max durability.
#[derive(Default)]
pub(super) struct ItemInstance {
/// The source's own display NAME, when it knows one the template cannot compose — today that
/// is exactly the **random-suffix roll**: "Chipped Claw" + `ItemRandomProperties[id].suffix`
/// = "Chipped Claw of the Bear". The reference composes it inside the builder (`0x52b7bf`
/// keeps the suffix row for the name, and `0x5d8b00` joins it through `ITEM_SUFFIX_TEMPLATE`);
/// here the app owns every DBC join, so it arrives already joined — the same division the
/// enchant lines below already use. `None` = the template's own name (`ItemTemplateView::name`).
pub name: Option<String>,
/// Live `(current, max)` durability (director-reported: the spirit healer's 25% loss
/// showed nowhere); `None` = indestructible (max 0) or the create not yet landed.
pub durability: Option<(u32, u32)>,
@@ -113,7 +120,10 @@ pub(super) fn render_view(
} else {
quality_color(v.quality)
};
add((v.name.clone(), name_color))?;
let name = inst
.and_then(|i| i.name.clone())
.unwrap_or_else(|| v.name.clone());
add((name, name_color))?;
// ITEM_SIGNABLE (green) — Flags bit 0x2000 (petitions).
if v.flags & 0x2000 != 0 {
add(("<Right Click for Details>".into(), GREEN))?;
@@ -305,8 +315,17 @@ pub(super) fn render_view(
false => inst.map(|i| i.enchants.as_slice()).unwrap_or_default(),
};
// "No id source" is the reference's own three-way fork (§E1): a wrapped gift, or no item
// object AND no caller-supplied instance block. Ours reduces to "this hover carries no
// instance" — a template or link hover — plus the wrapped-gift bit, which is the same set.
// object AND no caller-supplied instance block (`+0x440 == 0`). Ours reads the same: a hover
// that passes NO [`ItemInstance`] is a p6=0 leg — the template sources (merchant, quest,
// craft, buyback, send-mail, the compare legs, `SetItemById`) — plus the wrapped-gift bit.
//
// **A block-supplying source never prints the placeholder, even carrying no ids at all.** The
// fork tests the block's presence, not its contents, so `SetLootItem`/`SetHyperlink`/
// `SetInboxItem`/`SetAuctionItem`/`SetLootRollItem`/the trade legs fall into the slot loop and
// print whatever their slots hold — nothing, when the roll is absent. Decision 0920's prose
// put a hyperlink hover on the placeholder arm; §E1's `0x52c9a3` fork says otherwise, and
// that is the drift 1547 corrects (a linked or looted "of the Monkey" showed the placeholder
// where the reference shows the rolled lines).
let no_id_source = inst.is_none_or(|i| i.flags & 0x8 != 0);
if no_id_source && !signable && v.random_property != 0 {
add(("<Random enchantment>".into(), GREEN))?;
+6 -15
View File
@@ -140,19 +140,6 @@ fn flag(b: bool) -> Value {
}
}
/// An item's `isUsable`, the shared item-usable gate over the slot's template from the
/// merchant/tooltip store, keyed by the item id — usable while the template is still in flight (the
/// null-record skip, the mail/merchant path). Spell knowledge comes from the engine's spellbook
/// mirror, as the tooltip's "Requires <spell>" red does.
fn is_usable(model: &Model, item_id: u32) -> bool {
item_id == 0
|| model.item_templates.get(&item_id).is_none_or(|v| {
super::item_stats::item_usable(v, &model.player_req, |id| {
model.spellbook.slots.iter().any(|s| s.spell_id == id)
})
})
}
/// Fetch a cloned slot for a 1-based index off one side, or `None` (out of range / empty / no trade
/// open). `pick` selects the side from the pushed [`TradeState`].
fn slot_at(
@@ -218,7 +205,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let (slot, usable) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let slot = slot_at(&model, id, |t| &t.player);
let usable = slot.as_ref().is_none_or(|s| is_usable(&model, s.item_id));
let usable = slot
.as_ref()
.is_none_or(|s| super::item_stats::item_usable_by_id(&model, s.item_id));
(slot, usable)
};
let Some(s) = slot else {
@@ -248,7 +237,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let (slot, usable) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let slot = slot_at(&model, id, |t| &t.target);
let usable = slot.as_ref().is_none_or(|s| is_usable(&model, s.item_id));
let usable = slot
.as_ref()
.is_none_or(|s| super::item_stats::item_usable_by_id(&model, s.item_id));
(slot, usable)
};
let Some(s) = slot else {