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:
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user