druids get their relic slot, and the loot window is the reference's own
- `UnitHasRelicSlot` is real in 1.12 and benilla had no binding for it: `0x519e50` reads `ChrClasses.dbc` field 16 — set for Paladin, Shaman and Druid in `patch.MPQ`'s 17-column copy (the base `dbc.MPQ` copy has 16 columns and no such field, which is how the "always false in vanilla" belief survived twice in decisions and seven times in code) — and stock `PaperDollFrame.lua` calls it unconditionally, so the character sheet would have raised for EVERY class; a druid, paladin or shaman's sheet now hides the ammo slot, shows the relic plate where the bow was, tooltips "Relic", and reports ranged Attack/Damage/Power as N/A, all four branches transcribed from the reference - the loot window is the reference's own `LootFrame.xml`: `LootButton` is built as a real widget type rather than aliased (a Button plus one slot dword and one overridden OnClick — right-click loots exactly like left, no shift/ctrl/alt takes, a row whose Lua handler errored still loots, a fast double-click takes two slots), `LOOT_SLOT_CLEARED` fires per row again because the stock file hangs hide-in-place AND the emptied-page auto-advance off it, `EditBox:Insert(nil)` is a no-op as `lua_tostring` makes it, and an alias `GameTooltip.xml` had quietly become a reader of would have turned every item tooltip white, silently, had it just gone nil - the vendor cursor: `PickupMerchantItem` is two verbs behind one name, decided by what the cursor holds and never by the caller — a held bag item SELLS (index ignored, no merchant-open gate), otherwise the vendor row is grabbed as cursor mode 5, which is invisible to `CursorHasItem` (modes 1 and 9 only) so all thirteen stock gates stay closed while it is held; the drop is the buy, `CMSG_BUY_ITEM_IN_SLOT`, count 1, into a bag or an equipment slot, and every refusal is silent and clears the cursor; the four `Model_*` rotation handlers land in UIParent where the reference keeps them - the gap report reads `:Method(` sites now — it found `SetMerchantCompareItem`, which would have raised on the first vendor-row hover with every check green, and `LootButton:SetSlot`, and 28 "free" windows became 25; the shared coin kit moved out of the merchant window into `MoneyFrame.xml` so the eight windows that draw coins through it can reach it without loading a vendor Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -142,6 +142,29 @@ fn with_button<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Write [`ButtonState::loot_slot`] — `LootButton:SetSlot`'s whole body, kept here beside the
|
||||
/// other `ButtonState` writers rather than reaching into the arena from `script::loot`.
|
||||
pub(super) fn set_loot_slot(lua: &Lua, this: &Table, slot: Option<u32>) -> mlua::Result<()> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let frame = model
|
||||
.arena
|
||||
.frame_mut(h)
|
||||
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
|
||||
match (&frame.kind, &mut frame.kind_state) {
|
||||
(crate::widget::FrameKind::LootButton, KindState::Button(bs)) => {
|
||||
bs.loot_slot = slot;
|
||||
Ok(())
|
||||
}
|
||||
// The reference's own guard: `SetSlot` type-checks `this` against the LootButton tag via
|
||||
// vtable slot 4 (`0x4c18ee`), so calling it on a plain Button raises rather than writing a
|
||||
// field nothing would ever read.
|
||||
_ => Err(mlua::Error::runtime(
|
||||
"SetSlot: 'this' is not a LootButton widget",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get-or-create the region behind `slot`; returns its id.
|
||||
fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result<u32> {
|
||||
let h = frame_handle_of(lua, this)?;
|
||||
@@ -754,7 +777,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
};
|
||||
let btn = button.unwrap_or_else(|| "LeftButton".to_string());
|
||||
// A programmatic Click() always emulates a completed (released) click.
|
||||
click_button(lua, id, &btn, false);
|
||||
// A programmatic Click() — `scripted = true`, which only a LootButton reads.
|
||||
click_button(lua, id, &btn, false, true);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -866,12 +890,42 @@ pub(super) fn press_held(model: &Model, h: FrameHandle) -> bool {
|
||||
/// `self:GetChecked()` sees the new state); then `OnClick(self, button, down)` — `down` is `true`
|
||||
/// only for a press that fired via a `"<Button>ButtonDown"` registration, `false` for every
|
||||
/// release-fired or programmatic click (the Era signature: `down` mirrors which transition fired).
|
||||
pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool) {
|
||||
///
|
||||
/// `scripted` says the click came from Lua `:Click()` rather than from hardware — the reference's
|
||||
/// second `OnClick` argument (`0` from `0x779280`/`0x7793a4`, `1` from the `Button:Click` binding
|
||||
/// `0x7826c0`). Only [`crate::widget::FrameKind::LootButton`] reads it, and it reads it as a hard
|
||||
/// gate: `0x4c182b` returns before the base call, so **a scripted click on a loot row does
|
||||
/// nothing at all — not even run the row's own Lua `OnClick`**. Every other kind ignores it, which
|
||||
/// is the reference's shape too (the base `0x779540` never looks at the flag).
|
||||
pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool, scripted: bool) {
|
||||
let mut take_loot = None;
|
||||
let fire = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let Some(&h) = model.id_to_frame.get(&id) else {
|
||||
return;
|
||||
};
|
||||
let is_loot = model
|
||||
.arena
|
||||
.frame(h)
|
||||
.is_some_and(|f| f.kind == crate::widget::FrameKind::LootButton);
|
||||
if is_loot {
|
||||
if scripted {
|
||||
return;
|
||||
}
|
||||
// Read the take BEFORE the handler runs, but queue it after: the reference reads
|
||||
// `[esi+0x4dc]` after the base call, and a handler that re-slots its own row mid-click
|
||||
// is not a case the shipped UI produces — reading first keeps the borrow simple and
|
||||
// the two cannot disagree. The modifier gate is `0x4c183a`/`0x4c1848`/`0x4c1856`:
|
||||
// shift, ctrl OR alt suppresses, and it is the whole reason the shipped
|
||||
// `LootFrameItem_OnClick` never calls a take itself.
|
||||
let (shift, ctrl, alt) = model.modifiers;
|
||||
if !shift && !ctrl && !alt {
|
||||
take_loot = model.arena.frame(h).and_then(|f| match &f.kind_state {
|
||||
KindState::Button(bs) => bs.loot_slot,
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
}
|
||||
let Some(frame) = model.arena.frame_mut(h) else {
|
||||
return;
|
||||
};
|
||||
@@ -906,4 +960,15 @@ pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool) {
|
||||
.errors
|
||||
.push(e.to_string());
|
||||
}
|
||||
// …and then the take, unconditionally on the handler's outcome. `0x4c1867` is not guarded by
|
||||
// anything the Lua side did: the base call at `0x4c1833` returns void and its result is never
|
||||
// tested. A row whose `OnClick` errored still loots, which is the reference's behaviour and
|
||||
// the reason a broken addon hook cannot silently eat your loot.
|
||||
if let Some(slot) = take_loot {
|
||||
// The 0-based store, back to the 1-based display row the take queue speaks.
|
||||
lua.app_data_mut::<Model>()
|
||||
.expect("model app_data")
|
||||
.loot_picks
|
||||
.push(slot + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1108,9 +1108,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// GetInventorySlotInfo(slotName) → (slotId, textureName, checkRelic) — the client's own
|
||||
// PaperDollItemFrame.dbc rows (SLOT_INFO above). checkRelic is always false in vanilla
|
||||
// (UnitHasRelicSlot is a later-era concept; the 0208 deferral). An unknown name is a Lua
|
||||
// error, the client's own behavior.
|
||||
// PaperDollItemFrame.dbc rows (SLOT_INFO above). An unknown name is a Lua error, the client's
|
||||
// own behavior.
|
||||
//
|
||||
// `checkRelic` is the number 1 for the ranged slot and nil elsewhere, unconditionally — it is
|
||||
// a property of the SLOT, not of the player. It pairs with `UnitHasRelicSlot("player")`,
|
||||
// which is the class half, and stock reads the two as a conjunction
|
||||
// (`PaperDollFrame.lua:680`/`:744`). This comment used to say `UnitHasRelicSlot` is a
|
||||
// later-era concept, always false in vanilla; it is not — decision 1785.
|
||||
g.set(
|
||||
"GetInventorySlotInfo",
|
||||
lua.create_function(|lua, name: String| {
|
||||
|
||||
@@ -392,6 +392,31 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mode 5 — **the buy**. A held vendor row dropped into a container slot is the whole
|
||||
// point of the merchant cursor: `0x4f9efa` tests `[0xb4d900] == 5` right here and calls
|
||||
// `0x5e1f30` = `CMSG_BUY_ITEM_IN_SLOT 0x1a3`, `{vendorGuid, itemEntry, bagGuid, bagSlot,
|
||||
// count = 1}`. The cursor clears whether or not the server honours it.
|
||||
//
|
||||
// Deliberately NOT gated on the destination being empty. The reference does not look at
|
||||
// the slot at all — the server decides, and answers `SMSG_BUY_FAILED` when it will not.
|
||||
// What we DO refuse is a stale row: the reference's two container drop consumers
|
||||
// dereference the vendor row without re-checking it against a list that may have been
|
||||
// rewritten by a fresh `SMSG_LIST_INVENTORY`, which is a latent null deref there
|
||||
// (`0x4f9f35`, `0x4c7ea5`). We treat a row that no longer resolves as a refusal — that is
|
||||
// the third consumer `0x4c7300`'s own behaviour, and the one of the three that is right.
|
||||
Some(CursorPayload::Merchant(held)) => {
|
||||
let entry = model
|
||||
.merchant
|
||||
.as_ref()
|
||||
.and_then(|m| m.items.get(held.row as usize))
|
||||
.filter(|it| it.item_id == held.item_id)
|
||||
.map(|it| it.item_id);
|
||||
if let Some(entry) = entry {
|
||||
model.merchant_slot_buys.push((bag, slot, entry));
|
||||
}
|
||||
cursor::queue_cursor_update(model);
|
||||
true
|
||||
}
|
||||
Some(
|
||||
other @ (CursorPayload::Spell(_)
|
||||
| CursorPayload::Action(_)
|
||||
|
||||
@@ -55,6 +55,13 @@ pub enum CursorPayload {
|
||||
Action(CursorAction),
|
||||
Macro(CursorMacro),
|
||||
PetAction(CursorPetAction),
|
||||
/// A vendor row grabbed off the merchant window — **mode 5** (see [`CursorMerchantItem`]).
|
||||
///
|
||||
/// Deliberately NOT `Item`: the reference's `CursorHasItem 0x4895d0` answers truthy for modes
|
||||
/// **1 and 9 only**, so all thirteen stock `CursorHasItem()` gates (TradeFrame, MailFrame,
|
||||
/// PlayerFrame, TargetFrame, PetFrame, StaticPopup, the keyring…) exclude a vendor cursor.
|
||||
/// Making this an `Item` variant would silently open every one of them.
|
||||
Merchant(CursorMerchantItem),
|
||||
/// A pet picked up from the stable window — **mode 10** (see [`CursorStablePet`]).
|
||||
///
|
||||
/// Mode 10 was recorded as "class/talent ability (DBC)" until wow-re's stable-master carve
|
||||
@@ -66,6 +73,31 @@ pub enum CursorPayload {
|
||||
StablePet(CursorStablePet),
|
||||
}
|
||||
|
||||
/// A vendor row held on the cursor — payload **mode 5**, set by `PickupMerchantItem 0x4fb760`.
|
||||
///
|
||||
/// **A buy-on-drop cursor, not an item cursor.** The reference writes three globals and this
|
||||
/// mirrors all three: the item entry (`0xb4b41c`), the display id that drives the icon
|
||||
/// (`0xb4d8ec` — we carry the resolved texture path, which is the same thing one hop later), and
|
||||
/// the **0-based** vendor row (`0xb4b420`). Dropping it on a bag slot sends
|
||||
/// `CMSG_BUY_ITEM_IN_SLOT 0x1a3`; dropping it on the world, pressing ESC, or the vendor window
|
||||
/// closing is a plain clear with **no packet**.
|
||||
///
|
||||
/// No Lua predicate reports it. `CursorHasItem`, `CursorHasSpell`, `CursorHasMoney` and
|
||||
/// `CursorCanGoInSlot` are all nil for mode 5 — no registered binding exposes it at all.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CursorMerchantItem {
|
||||
/// The row's item template entry — `0xb4b41c`, and what the buy addresses.
|
||||
pub item_id: u32,
|
||||
/// The vendor row this came from, **0-based** (`0xb4b420` = `trunc(arg) - 1`). Kept because
|
||||
/// the reference keeps it: a second `PickupMerchantItem` naming the same row is a toggle-off,
|
||||
/// which is the only thing that reads it back.
|
||||
pub row: u32,
|
||||
/// The icon drawn at the mouse. The reference stores `ItemDisplayInfo`'s id (`0xb4d8ec`) and
|
||||
/// resolves the art from it; we hold the already-resolved path, which is the same value one
|
||||
/// hop later and the shape every other payload here uses.
|
||||
pub texture: Option<String>,
|
||||
}
|
||||
|
||||
/// A pet held on the cursor from the stable window — payload **mode 10** (`0x495020`, the grab
|
||||
/// `0x495010`).
|
||||
///
|
||||
@@ -299,7 +331,11 @@ pub(crate) fn clear_cursor(model: &mut Model) {
|
||||
| CursorPayload::Action(_)
|
||||
| CursorPayload::Macro(_)
|
||||
| CursorPayload::PetAction(_)
|
||||
| CursorPayload::StablePet(_),
|
||||
| CursorPayload::StablePet(_)
|
||||
// Mode 5 clears like every other non-item payload, and clearing it sends NO packet —
|
||||
// a vendor cursor abandoned on the world, on ESC, or by the window closing costs
|
||||
// nothing. `ClearCursor`'s own mode-5 arm is `0x49525f`.
|
||||
| CursorPayload::Merchant(_),
|
||||
) => {
|
||||
queue_cursor_update(model);
|
||||
}
|
||||
@@ -595,6 +631,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Some(CursorPayload::StablePet(_)) => {
|
||||
Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil))
|
||||
}
|
||||
// Mode 5 — the vendor grab. Reported as nothing, for the same reason and with a
|
||||
// stronger warrant: **no registered binding in the 5875 image exposes mode 5 to
|
||||
// Lua at all.** `CursorHasItem`, `CursorHasSpell`, `CursorHasMoney` and
|
||||
// `CursorCanGoInSlot` are each nil for it, by census. A vendor cursor is invisible
|
||||
// from Lua and that invisibility is load-bearing — it is what keeps all thirteen
|
||||
// stock `CursorHasItem()` gates closed while one is held.
|
||||
Some(CursorPayload::Merchant(_)) => {
|
||||
Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil))
|
||||
}
|
||||
None => Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil)),
|
||||
}
|
||||
})?,
|
||||
|
||||
@@ -126,6 +126,10 @@ pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
|
||||
// Mode 10 (decision 1677) — a stabled pet has no `CMSG_SET_ACTION_BUTTON` encoding either,
|
||||
// so the action bar refuses it and it goes back on the cursor.
|
||||
CursorPayload::StablePet(_) => None,
|
||||
// Mode 5 — a vendor row is not a bar action. `PlaceAction`'s payload table takes modes
|
||||
// 1, 2 and 8 only, and the merchant grab is none of them, so it goes back on the cursor
|
||||
// where the vendor window can still take it.
|
||||
CursorPayload::Merchant(_) => None,
|
||||
};
|
||||
let Some((kind, action, texture)) = placeable else {
|
||||
model.cursor = Some(held);
|
||||
|
||||
@@ -105,6 +105,27 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
|
||||
queue_lock_changed(model, held.bag, held.slot);
|
||||
true
|
||||
}
|
||||
// Mode 5 — the buy, aimed at an equipment slot. `0x4c78fd` tests `[0xb4d900] == 5` inside
|
||||
// the paper-doll click and sends the same `CMSG_BUY_ITEM_IN_SLOT 0x1a3` the bag drop does;
|
||||
// the server equips it or refuses. Reported in the ONE bag space ([`EQUIPMENT_BAG`] + the
|
||||
// 1-based inventory slot), the seam this file already uses for every other doll intent.
|
||||
//
|
||||
// Of the reference's three drop consumers this is the one that handles a stale vendor row
|
||||
// correctly — the other two dereference it (`0x4f9f35`, `0x4c7ea5`) — so its refusal is
|
||||
// what we reproduce in all three.
|
||||
Some(CursorPayload::Merchant(held)) => {
|
||||
let entry = model
|
||||
.merchant
|
||||
.as_ref()
|
||||
.and_then(|m| m.items.get(held.row as usize))
|
||||
.filter(|it| it.item_id == held.item_id)
|
||||
.map(|it| it.item_id);
|
||||
if let Some(entry) = entry {
|
||||
model.merchant_slot_buys.push((EQUIPMENT_BAG, id, entry));
|
||||
}
|
||||
queue_cursor_update(model);
|
||||
true
|
||||
}
|
||||
Some(
|
||||
other @ (CursorPayload::Spell(_)
|
||||
| CursorPayload::Action(_)
|
||||
|
||||
@@ -53,11 +53,26 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Ok(text.trim().parse::<f64>().unwrap_or(0.0))
|
||||
})?,
|
||||
)?;
|
||||
// Insert(text) — **a nil is a no-op, not an error.** The reference's C `Insert` reads its
|
||||
// argument through `lua_tostring`, which answers NULL for a nil and leaves the buffer alone;
|
||||
// stock FrameXML relies on that. `LootFrame.lua:152` is the plain case:
|
||||
//
|
||||
// ChatFrameEditBox:Insert(GetLootSlotLink(this.slot));
|
||||
//
|
||||
// with no guard, on a coin row whose `GetLootSlotLink` is nil. Typed as `String`, this raised
|
||||
// — which the loot window only survived while we owned the file and could add a guard the
|
||||
// reference does not have. It bit the moment `LootFrame.xml` came off the player's chain
|
||||
// (1751), and it would bite any addon writing the same unguarded line.
|
||||
//
|
||||
// A NUMBER still inserts its digits: `lua_tostring` converts one in place, and mlua's
|
||||
// `Option<String>` coercion follows it.
|
||||
m.set(
|
||||
"Insert",
|
||||
lua.create_function(|lua, (this, s): (Table, String)| {
|
||||
lua.create_function(|lua, (this, s): (Table, Option<String>)| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
insert(lua, h, &s, true);
|
||||
if let Some(s) = s {
|
||||
insert(lua, h, &s, true);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -41,10 +41,15 @@
|
||||
//! `OPEN_MASTER_LOOT_LIST` when a picked row's wire `slot_type` is `MASTER`, which is where the
|
||||
//! real client puts it too — its take dispatcher branches on the same byte before any Lua runs.
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
use super::Model;
|
||||
|
||||
/// `CLootButton`'s own Lua method table (`0x847ce4`) — see [`crate::widget::FrameKind::LootButton`].
|
||||
/// Exactly one entry, and the count is read off the registrar's `mov edx,1` rather than off a run
|
||||
/// length, so it cannot drift.
|
||||
pub(super) const REG_LOOTBUTTON_METHODS: &str = "__benilla_lootbutton_methods";
|
||||
|
||||
/// One loot-window row, resolved by the app (decision 0084). Plain data — its 1-based order in the
|
||||
/// window is its position in [`LootState::rows`]; the coin row (when present) is always position 1.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -271,6 +276,42 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ── `LootButton`'s own method table — ONE entry, and this is it ─────────────────────────────
|
||||
//
|
||||
// `SetSlot(index)` (`0x4c1880`, table `0x847ce4`, the registrar's own `mov edx,1` says count
|
||||
// 1). 1-based in, **0-based stored** (`ftol` then `dec eax` into `[this+0x4dc]`), and it
|
||||
// pushes nothing. `LootFrame.lua:94`'s `button:SetSlot(slot)` is its only caller in the
|
||||
// shipped UI — the row's `id="N"` does NOT feed it.
|
||||
//
|
||||
// A non-number raises the client's own usage string. That is worth transcribing exactly: it
|
||||
// is the one place this class talks to a caller, and `LootFrame.lua` would surface a typo'd
|
||||
// call as this text.
|
||||
{
|
||||
let m = lua.create_table()?;
|
||||
m.set(
|
||||
"SetSlot",
|
||||
lua.create_function(|lua, (this, index): (Table, Value)| {
|
||||
let n = match &index {
|
||||
Value::Integer(i) => *i as f64,
|
||||
Value::Number(n) => *n,
|
||||
// `lua_isnumber` accepts a numeric string, as everywhere else in this image.
|
||||
Value::String(s) => match s.to_str().ok().and_then(|s| s.parse::<f64>().ok()) {
|
||||
Some(n) => n,
|
||||
None => return Err(mlua::Error::runtime("Usage: SetSlot(index)")),
|
||||
},
|
||||
_ => return Err(mlua::Error::runtime("Usage: SetSlot(index)")),
|
||||
};
|
||||
// `ftol` truncates toward zero, then `dec`. A slot below 1 leaves the row taking
|
||||
// nothing rather than wrapping — the reference stores the decrement raw, but its
|
||||
// consumer is a bounds-checked table walk and ours is an Option.
|
||||
let slot = (n.trunc() >= 1.0).then(|| n.trunc() as u32 - 1);
|
||||
super::button::set_loot_slot(lua, &this, slot)?;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
lua.set_named_registry_value(REG_LOOTBUTTON_METHODS, m)?;
|
||||
}
|
||||
|
||||
// BenillaTakeLootSlot(slot) — the ROW CLICK's take, queued as a 1-based display row; the app
|
||||
// maps it to the coin or the item's wire slot and applies the bind-on-pickup gate.
|
||||
//
|
||||
@@ -624,4 +665,202 @@ mod tests {
|
||||
assert_eq!(s.eval::<i64>("return GetNumLootItems()").unwrap(), 0);
|
||||
assert!(s.eval::<bool>("return GetLootSlotInfo(1) == nil").unwrap());
|
||||
}
|
||||
|
||||
/// A real HARDWARE click on a named frame — through the pointer path, so `scripted` is false
|
||||
/// and the LootButton gate sees what it would see in play. Positions the frame first: the
|
||||
/// input path is a hit test, and an unpositioned frame is nowhere.
|
||||
fn hardware_click(s: &mut UiScript, name: &str, button: &str) {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(&format!(
|
||||
"{name}:ClearAllPoints() {name}:SetPoint(\"BOTTOMLEFT\", 100, 100) \
|
||||
{name}:SetWidth(50) {name}:SetHeight(50) {name}:EnableMouse(true) {name}:Show()"
|
||||
))
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.mouse_button(125.0, 125.0, button, true);
|
||||
s.mouse_button(125.0, 125.0, button, false);
|
||||
}
|
||||
|
||||
/// `LootButton` is a real `CreateFrame` type with its own identity — not an alias for Button.
|
||||
#[test]
|
||||
fn loot_button_is_its_own_registered_type() {
|
||||
let s = UiScript::new().unwrap();
|
||||
s.run(r#"lb = CreateFrame("LootButton", "LB1", UIParent)"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return lb:GetObjectType()").unwrap(),
|
||||
"LootButton",
|
||||
"0x495b60 returns its own name, not \"Button\""
|
||||
);
|
||||
// `0x495af0` prepends its name to the base's three.
|
||||
for t in ["LootButton", "Button", "Frame", "Region"] {
|
||||
assert!(
|
||||
s.eval::<bool>(&format!("return lb:IsObjectType({t:?}) and true or false"))
|
||||
.unwrap(),
|
||||
"IsObjectType({t:?})"
|
||||
);
|
||||
}
|
||||
assert!(!s
|
||||
.eval::<bool>(r#"return lb:IsObjectType("CheckButton") and true or false"#)
|
||||
.unwrap());
|
||||
// Its one method of its own, plus all of Button's through the chain.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(lb.SetSlot)").unwrap(),
|
||||
"function"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(lb.SetText)").unwrap(),
|
||||
"function"
|
||||
);
|
||||
// …and the method is NOT on a plain Button — the chain runs derived → base only.
|
||||
s.run(r#"b = CreateFrame("Button", "PlainB", UIParent)"#)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<String>("return type(b.SetSlot)").unwrap(), "nil");
|
||||
}
|
||||
|
||||
/// An unmodified hardware click takes the row's slot. The Lua `OnClick` runs first and
|
||||
/// unconditionally, and its outcome does not gate the take.
|
||||
#[test]
|
||||
fn an_unmodified_click_runs_the_handler_and_then_takes() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
lb = CreateFrame("LootButton", "LB1", UIParent)
|
||||
lb:SetSlot(3)
|
||||
ran = 0
|
||||
lb:SetScript("OnClick", function() ran = ran + 1 end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
hardware_click(&mut s, "LB1", "LeftButton");
|
||||
assert_eq!(s.eval::<i64>("return ran").unwrap(), 1, "the handler ran");
|
||||
assert_eq!(s.take_loot_picks(), vec![3], "and then the take, 1-based");
|
||||
|
||||
// The handler erroring does not eat the loot — `0x4c1833`'s result is never tested.
|
||||
s.run(r#"lb:SetScript("OnClick", function() error("boom") end)"#)
|
||||
.unwrap();
|
||||
hardware_click(&mut s, "LB1", "LeftButton");
|
||||
assert_eq!(s.take_loot_picks(), vec![3], "a broken hook still loots");
|
||||
}
|
||||
|
||||
/// Right-click loots exactly like left-click: `0x4c1820` reads the button code once and
|
||||
/// forwards it, with no `cmp` against it anywhere in the body.
|
||||
#[test]
|
||||
fn right_click_loots_like_left_click() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
lb = CreateFrame("LootButton", "LB1", UIParent)
|
||||
lb:SetSlot(2)
|
||||
lb:RegisterForClicks("LeftButtonUp", "RightButtonUp")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
hardware_click(&mut s, "LB1", "RightButton");
|
||||
assert_eq!(s.take_loot_picks(), vec![2]);
|
||||
}
|
||||
|
||||
/// Any of the three modifiers suppresses the take — and only the take. The handler still runs,
|
||||
/// which is exactly how the shipped `LootFrameItem_OnClick` gets to own the ctrl and shift
|
||||
/// cases without the C take firing underneath it.
|
||||
#[test]
|
||||
fn any_modifier_suppresses_the_take_but_not_the_handler() {
|
||||
for (i, (shift, ctrl, alt)) in [
|
||||
(true, false, false),
|
||||
(false, true, false),
|
||||
(false, false, true),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
lb = CreateFrame("LootButton", "LB1", UIParent)
|
||||
lb:SetSlot(1)
|
||||
ran = 0
|
||||
lb:SetScript("OnClick", function() ran = ran + 1 end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.set_modifiers(shift, ctrl, alt);
|
||||
hardware_click(&mut s, "LB1", "LeftButton");
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return ran").unwrap(),
|
||||
1,
|
||||
"case {i}: handler ran"
|
||||
);
|
||||
assert!(s.take_loot_picks().is_empty(), "case {i}: no take");
|
||||
}
|
||||
}
|
||||
|
||||
/// **A scripted `:Click()` is a complete no-op** — it does not even run the row's `OnClick`.
|
||||
/// `0x4c182b` returns before the base call. Surprising enough that it is asserted rather than
|
||||
/// left to be rediscovered.
|
||||
#[test]
|
||||
fn a_scripted_click_does_nothing_at_all() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(
|
||||
r#"
|
||||
lb = CreateFrame("LootButton", "LB1", UIParent)
|
||||
lb:SetSlot(1)
|
||||
ran = 0
|
||||
lb:SetScript("OnClick", function() ran = ran + 1 end)
|
||||
lb:Click()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return ran").unwrap(),
|
||||
0,
|
||||
"the handler never ran"
|
||||
);
|
||||
assert!(s.take_loot_picks().is_empty(), "and nothing was taken");
|
||||
// A plain Button's Click() is unaffected — the gate is this type's alone.
|
||||
s.run(
|
||||
r#"
|
||||
b = CreateFrame("Button", "PlainB", UIParent)
|
||||
bran = 0
|
||||
b:SetScript("OnClick", function() bran = bran + 1 end)
|
||||
b:Click()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<i64>("return bran").unwrap(), 1);
|
||||
}
|
||||
|
||||
/// `SetSlot` is 1-based in and 0-based stored, takes a numeric string, and refuses `this` that
|
||||
/// is not a LootButton (the reference's own `IsA` guard at `0x4c18ee`).
|
||||
#[test]
|
||||
fn set_slot_converts_and_guards() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.run(r#"lb = CreateFrame("LootButton", "LB1", UIParent)"#)
|
||||
.unwrap();
|
||||
|
||||
// `ftol` truncates toward zero: 4.9 is row 4, not 5.
|
||||
s.run("lb:SetSlot(4.9)").unwrap();
|
||||
hardware_click(&mut s, "LB1", "LeftButton");
|
||||
assert_eq!(s.take_loot_picks(), vec![4]);
|
||||
|
||||
// A numeric string is a number to `lua_isnumber`.
|
||||
s.run(r#"lb:SetSlot("2")"#).unwrap();
|
||||
hardware_click(&mut s, "LB1", "LeftButton");
|
||||
assert_eq!(s.take_loot_picks(), vec![2]);
|
||||
|
||||
// A non-number raises the client's own usage text.
|
||||
let e = s.run("lb:SetSlot('x')").unwrap_err().to_string();
|
||||
assert!(e.contains("Usage: SetSlot(index)"), "{e}");
|
||||
|
||||
// Never slotted → takes nothing, rather than silently taking row 1.
|
||||
s.run(r#"fresh = CreateFrame("LootButton", "LB2", UIParent)"#)
|
||||
.unwrap();
|
||||
hardware_click(&mut s, "LB2", "LeftButton");
|
||||
assert!(s.take_loot_picks().is_empty());
|
||||
|
||||
// And it is a LootButton method, not a Button one.
|
||||
s.run(r#"b = CreateFrame("Button", "PlainB", UIParent)"#)
|
||||
.unwrap();
|
||||
let e = s.run("LB1.SetSlot(b, 1)").unwrap_err().to_string();
|
||||
assert!(e.contains("not a LootButton"), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::container::UiCursorMode;
|
||||
use super::cursor::CursorPayload;
|
||||
use super::Model;
|
||||
|
||||
/// One vendor row, resolved by the app from the wire `VendorItem` (decision 0081). Plain data —
|
||||
@@ -132,6 +133,20 @@ impl super::UiScript {
|
||||
std::mem::take(&mut self.model_mut().merchant_buys)
|
||||
}
|
||||
|
||||
/// Drain the `(bag, slot)` sells `PickupMerchantItem`'s cursor arm queued — the item the
|
||||
/// cursor was holding when it was dropped on the vendor window. The app resolves the concrete
|
||||
/// item guid from the pair and sends `CMSG_SELL_ITEM`, the same resolution the bag-click sell
|
||||
/// route already does.
|
||||
pub fn take_merchant_cursor_sells(&mut self) -> Vec<(i64, u32)> {
|
||||
std::mem::take(&mut self.model_mut().merchant_cursor_sells)
|
||||
}
|
||||
|
||||
/// Drain the `(bag, slot, item entry)` buys a held vendor row queued by being dropped into a
|
||||
/// container slot — `CMSG_BUY_ITEM_IN_SLOT 0x1a3`.
|
||||
pub fn take_merchant_slot_buys(&mut self) -> Vec<(i64, u32, u32)> {
|
||||
std::mem::take(&mut self.model_mut().merchant_slot_buys)
|
||||
}
|
||||
|
||||
/// Whether `CloseMerchant` was called since the last drain (and clear the flag). vanilla's
|
||||
/// client-side close sends no packet — the app just clears its local merchant state.
|
||||
pub fn take_merchant_close(&mut self) -> bool {
|
||||
@@ -327,6 +342,86 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// PickupMerchantItem(index) — **two verbs behind one name** (`0x4fb760`, wow-re
|
||||
// `system/ui/scratch/merchant-cursor-law.md`). `0x4fb787` calls `GetCursorItem 0x494c60`,
|
||||
// which answers for **mode 1 only**, and the result forks the whole function:
|
||||
//
|
||||
// * cursor holds a real bag item → **SELL it** (`CMSG_SELL_ITEM 0x1a0`), then clear the
|
||||
// cursor. The index is IGNORED on this arm and there is no merchant-open gate on it. This
|
||||
// is what stock `MerchantFrame.xml:743`'s `<OnMouseUp>PickupMerchantItem(0)</OnMouseUp>`
|
||||
// is for: dropping a bag item anywhere on the vendor window sells it.
|
||||
// * otherwise → **grab** vendor row `index - 1` as cursor mode 5.
|
||||
//
|
||||
// Stock FrameXML never guards this call with `CursorHasItem()` — `MerchantFrame.lua:329`'s
|
||||
// `else` is the else of the ctrl/shift modifier chain. An unmodified left-click calls it
|
||||
// whatever the cursor holds, and the binding decides.
|
||||
//
|
||||
// **Every refusal is silent, and every refusal CLEARS THE CURSOR** — there is no `luaL_error`
|
||||
// anywhere in the range, unlike its neighbour `BuyMerchantItem`. The ladder, in order:
|
||||
// non-number argument (`0x4fb7cb`; a numeric *string* is accepted), `index < 1` (`0x4fb7e1`),
|
||||
// `index > count` (`0x4fb7e9`), no merchant open (`0x4fb7f7`), a dead row (`0x4fb80b`), and a
|
||||
// re-click on the row already held (`0x4fb818`, a toggle-off). Conversion is `_ftol` —
|
||||
// **truncate toward zero**, then `-1`, bounded signed.
|
||||
//
|
||||
// It does **not** test stock and does **not** test the item cache: a sold-out row grabs fine
|
||||
// and the buy still goes out, and the server refuses it. `numAvailable` is read exactly once
|
||||
// image-wide, by `GetMerchantItemInfo`, for display.
|
||||
g.set(
|
||||
"PickupMerchantItem",
|
||||
lua.create_function(|lua, index: Option<f64>| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
|
||||
// The sell fork, and it comes FIRST — before the argument is even looked at.
|
||||
if let Some(CursorPayload::Item(item)) = &model.cursor {
|
||||
let pair = (item.bag, item.slot);
|
||||
model.cursor = None;
|
||||
model.merchant_cursor_sells.push(pair);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// From here every leg clears the cursor and answers nothing. Holding a mode-5 payload
|
||||
// and calling this again is the toggle-off, which is the same clear.
|
||||
let held = match &model.cursor {
|
||||
Some(CursorPayload::Merchant(m)) => Some(m.row),
|
||||
_ => None,
|
||||
};
|
||||
model.cursor = None;
|
||||
|
||||
let Some(index) = index else { return Ok(()) };
|
||||
// `_ftol` truncates toward zero; the bound is then applied signed, so a negative or
|
||||
// a zero index falls out here rather than wrapping into a row. NaN is written as its
|
||||
// own test rather than as a negated `>=` — same answer, and the intent is on the page.
|
||||
//
|
||||
// The upper bound is ours and it is load-bearing, not defensive: without it a Lua
|
||||
// index of `2^32 + 1` truncates on the cast to row 0 and would grab the FIRST row.
|
||||
// The reference cannot have that bug — its bound is against the vendor count, which
|
||||
// it applies before any narrowing.
|
||||
let row = index.trunc();
|
||||
if row.is_nan() || row < 1.0 || row > f64::from(u32::MAX) {
|
||||
return Ok(());
|
||||
}
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
let row0 = row as u32 - 1;
|
||||
let Some(merchant) = model.merchant.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(item) = merchant.items.get(row0 as usize) else {
|
||||
return Ok(());
|
||||
};
|
||||
// The toggle-off: naming the row already on the cursor puts nothing back.
|
||||
if held == Some(row0) {
|
||||
return Ok(());
|
||||
}
|
||||
let payload = CursorPayload::Merchant(super::cursor::CursorMerchantItem {
|
||||
item_id: item.item_id,
|
||||
row: row0,
|
||||
texture: item.texture.clone(),
|
||||
});
|
||||
model.cursor = Some(payload);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// CloseMerchant() — client-side close (no packet, vanilla): flag it so the app clears its state.
|
||||
g.set(
|
||||
"CloseMerchant",
|
||||
@@ -908,4 +1003,152 @@ mod tests {
|
||||
s.run("ShowMerchantSellCursor(99)").unwrap();
|
||||
assert_eq!(s.ui_cursor(), Some(UiCursorMode::Buy));
|
||||
}
|
||||
/// `PickupMerchantItem` is TWO verbs, and which one runs is decided by the cursor, never by
|
||||
/// the caller — stock FrameXML never guards it (wow-re `merchant-cursor-law.md`).
|
||||
#[test]
|
||||
fn pickup_merchant_item_grabs_a_row_as_mode_5() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_merchant(Some(stock()));
|
||||
|
||||
s.run("PickupMerchantItem(1)").unwrap();
|
||||
// Mode 5 is INVISIBLE to Lua. This is the load-bearing assertion in the whole file: all
|
||||
// thirteen stock `CursorHasItem()` gates stay closed while a vendor cursor is held.
|
||||
assert!(
|
||||
s.eval::<bool>("return not CursorHasItem()").unwrap(),
|
||||
"CursorHasItem is nil for mode 5"
|
||||
);
|
||||
assert!(s.eval::<bool>("return not CursorHasSpell()").unwrap());
|
||||
assert!(
|
||||
s.eval::<bool>("return GetCursorInfo() == nil").unwrap(),
|
||||
"GetCursorInfo reports nothing — no binding exposes mode 5"
|
||||
);
|
||||
|
||||
// …but it IS held: a second call naming the same row is the toggle-off (`0x4fb818`), and
|
||||
// a third call re-grabs it. Nothing else can observe the difference from Lua, so the
|
||||
// toggle is what the test reads it through.
|
||||
s.run("PickupMerchantItem(1)").unwrap();
|
||||
s.run("PickupMerchantItem(1)").unwrap();
|
||||
assert!(s.take_merchant_slot_buys().is_empty(), "no drop yet");
|
||||
}
|
||||
|
||||
/// Every refusal is silent AND clears the cursor — there is no `luaL_error` in the range.
|
||||
#[test]
|
||||
fn pickup_merchant_item_refuses_silently_and_clears() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_merchant(Some(stock()));
|
||||
|
||||
for bad in [
|
||||
"PickupMerchantItem(0)",
|
||||
"PickupMerchantItem(-1)",
|
||||
"PickupMerchantItem(99)",
|
||||
] {
|
||||
s.run("PickupMerchantItem(1)").unwrap(); // hold something first
|
||||
s.run(bad)
|
||||
.unwrap_or_else(|e| panic!("{bad} must not raise: {e}"));
|
||||
assert!(
|
||||
s.eval::<bool>("return GetCursorInfo() == nil").unwrap(),
|
||||
"{bad} cleared the cursor"
|
||||
);
|
||||
}
|
||||
// A missing argument is the same silent clear, not an error.
|
||||
s.run("PickupMerchantItem(1)").unwrap();
|
||||
s.run("PickupMerchantItem()").unwrap();
|
||||
// A wrapping index must not become a valid row: `2^32 + 1` narrows to 0 on a naive cast
|
||||
// and would grab row 1. It refuses, and the cursor stays empty.
|
||||
s.run("PickupMerchantItem(4294967297)").unwrap();
|
||||
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
|
||||
s.run("PickupContainerItem(0, 3)").unwrap();
|
||||
assert!(
|
||||
s.take_merchant_slot_buys().is_empty(),
|
||||
"the wrapping index grabbed nothing"
|
||||
);
|
||||
// A numeric STRING is accepted (`_ftol` over a converted argument), and `1.9` truncates
|
||||
// toward zero to row 1 — not rounds to 2.
|
||||
s.run(r#"PickupMerchantItem("1")"#).unwrap();
|
||||
s.run("PickupMerchantItem(1.9)").unwrap();
|
||||
// …which the toggle proves: 1.9 named the row 1 already held, so it toggled off.
|
||||
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
|
||||
}
|
||||
|
||||
/// The sell arm: a bag item on the cursor makes this a sell, the index is ignored, and the
|
||||
/// cursor empties. This is what stock `MerchantFrame.xml:743`'s `PickupMerchantItem(0)` is —
|
||||
/// an index the grab arm would refuse outright.
|
||||
#[test]
|
||||
fn pickup_merchant_item_sells_what_the_cursor_holds() {
|
||||
use crate::script::cursor::{CursorItem, CursorPayload};
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_merchant(Some(stock()));
|
||||
s.model_mut().cursor = Some(CursorPayload::Item(CursorItem {
|
||||
bag: 0,
|
||||
slot: 7,
|
||||
item_id: 4540,
|
||||
texture: None,
|
||||
link: None,
|
||||
count: None,
|
||||
quality: None,
|
||||
equip_slots: Vec::new(),
|
||||
bar_placeable: false,
|
||||
}));
|
||||
|
||||
s.run("PickupMerchantItem(0)").unwrap();
|
||||
assert_eq!(
|
||||
s.take_merchant_cursor_sells(),
|
||||
vec![(0, 7)],
|
||||
"index 0 is ignored — the cursor decides"
|
||||
);
|
||||
assert!(s.eval::<bool>("return not CursorHasItem()").unwrap());
|
||||
assert!(s.take_merchant_cursor_sells().is_empty(), "drained");
|
||||
}
|
||||
|
||||
/// The drop is the buy. A held row placed into a bag slot queues `CMSG_BUY_ITEM_IN_SLOT`.
|
||||
#[test]
|
||||
fn a_held_vendor_row_dropped_in_a_bag_is_a_buy() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_merchant(Some(stock()));
|
||||
s.run("PickupMerchantItem(1)").unwrap();
|
||||
s.run("PickupContainerItem(0, 3)").unwrap();
|
||||
assert_eq!(
|
||||
s.take_merchant_slot_buys(),
|
||||
vec![(0, 3, 159)],
|
||||
"the row's item entry, aimed at the dropped-on slot"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return GetCursorInfo() == nil").unwrap(),
|
||||
"the cursor clears on the drop"
|
||||
);
|
||||
}
|
||||
|
||||
/// A stale row is a refusal, not a wrong buy. The reference has a latent null deref on two of
|
||||
/// its three drop paths for exactly this case (a fresh `SMSG_LIST_INVENTORY` rewrites the
|
||||
/// count without clearing the cursor); we take the third path's behaviour on all of them.
|
||||
#[test]
|
||||
fn a_stale_vendor_row_drops_without_buying() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_merchant(Some(stock()));
|
||||
s.run("PickupMerchantItem(2)").unwrap();
|
||||
// The vendor list is replaced while the cursor still holds row 2 of the old one.
|
||||
let mut shorter = stock();
|
||||
shorter.items.truncate(1);
|
||||
s.set_merchant(Some(shorter));
|
||||
s.run("PickupContainerItem(0, 3)").unwrap();
|
||||
assert!(
|
||||
s.take_merchant_slot_buys().is_empty(),
|
||||
"no buy goes out for a row that no longer resolves"
|
||||
);
|
||||
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
|
||||
}
|
||||
|
||||
/// A vendor cursor is not a bar action and not an equip: `PlaceAction` refuses it and hands it
|
||||
/// back, exactly as it does the pet-action and stable-pet payloads.
|
||||
#[test]
|
||||
fn the_action_bar_refuses_a_vendor_row_and_keeps_it_held() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_merchant(Some(stock()));
|
||||
s.run("PickupMerchantItem(1)").unwrap();
|
||||
s.run("PlaceAction(1)").unwrap();
|
||||
// Still held, proven positively: dropping it in a bag afterwards still buys. An asserted
|
||||
// *absence* here would pass just as well if the bar had eaten the payload.
|
||||
s.run("PickupContainerItem(0, 5)").unwrap();
|
||||
assert_eq!(s.take_merchant_slot_buys(), vec![(0, 5, 159)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,8 +171,8 @@ pub use container::{
|
||||
};
|
||||
pub use craft::{CraftReagent, CraftRecipe, CraftState, CraftTooltip};
|
||||
pub use cursor::{
|
||||
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorPetAction, CursorSpell,
|
||||
CursorStablePet, EnchantConfirm, WorldPick, EQUIPMENT_BAG,
|
||||
CursorAction, CursorItem, CursorMacro, CursorMerchantItem, CursorPayload, CursorPetAction,
|
||||
CursorSpell, CursorStablePet, EnchantConfirm, WorldPick, EQUIPMENT_BAG,
|
||||
};
|
||||
pub use cvars::{MultisampleFormat, CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS};
|
||||
pub use death::{DeathAction, DeathUiState};
|
||||
@@ -1379,6 +1379,9 @@ impl UiScript {
|
||||
crate::widget::FrameKind::Frame => "Frame",
|
||||
crate::widget::FrameKind::Button => "Button",
|
||||
crate::widget::FrameKind::CheckButton => "CheckButton",
|
||||
// `GetObjectType 0x495b60` is two instructions and returns `"LootButton"`
|
||||
// (`[0x847ce0]` → `0x843414`). It does NOT answer "Button".
|
||||
crate::widget::FrameKind::LootButton => "LootButton",
|
||||
crate::widget::FrameKind::EditBox => "EditBox",
|
||||
crate::widget::FrameKind::StatusBar => "StatusBar",
|
||||
crate::widget::FrameKind::Slider => "Slider",
|
||||
|
||||
@@ -901,6 +901,12 @@ pub(crate) struct Model {
|
||||
/// seam ([`merchant`]).
|
||||
pub(crate) merchant: Option<merchant::MerchantState>,
|
||||
pub(crate) merchant_buys: Vec<(u32, u32)>,
|
||||
/// `PickupMerchantItem`'s SELL arm — the `(bag, slot)` the cursor was holding when it was
|
||||
/// called over the vendor window. The app resolves the guid and sends `CMSG_SELL_ITEM`.
|
||||
pub(crate) merchant_cursor_sells: Vec<(i64, u32)>,
|
||||
/// A held vendor row dropped into a bag — `(bag, slot, item entry)`, the app's
|
||||
/// `CMSG_BUY_ITEM_IN_SLOT`.
|
||||
pub(crate) merchant_slot_buys: Vec<(i64, u32, u32)>,
|
||||
pub(crate) merchant_close: bool,
|
||||
/// `BuybackItem` intents (1-based buyback slots), the `RepairAllItems` flag, and the
|
||||
/// client-side repair-mode latch (`ShowRepairCursor`/`InRepairMode`) — the rest of the
|
||||
@@ -1667,6 +1673,8 @@ impl Model {
|
||||
gossip_quest_selects: Vec::new(),
|
||||
merchant: None,
|
||||
merchant_buys: Vec::new(),
|
||||
merchant_cursor_sells: Vec::new(),
|
||||
merchant_slot_buys: Vec::new(),
|
||||
merchant_close: false,
|
||||
merchant_buybacks: Vec::new(),
|
||||
repair_all: false,
|
||||
|
||||
@@ -197,6 +197,11 @@ fn frame_kind_from_str(s: &str) -> Option<FrameKind> {
|
||||
"FRAME" => FrameKind::Frame,
|
||||
"BUTTON" => FrameKind::Button,
|
||||
"CHECKBUTTON" => FrameKind::CheckButton,
|
||||
// A real registered type (`0x4959a6`), not an alias for Button — decision 1788. Registering
|
||||
// it AS a Button would load `LootFrame.xml` and leave every row dead, because 1.12 has no
|
||||
// Lua verb for "take slot N": `LootSlot` is the bind-confirm continuation, hard-wired to
|
||||
// `flag = 1`.
|
||||
"LOOTBUTTON" => FrameKind::LootButton,
|
||||
"EDITBOX" => FrameKind::EditBox,
|
||||
"STATUSBAR" => FrameKind::StatusBar,
|
||||
"SLIDER" => FrameKind::Slider,
|
||||
@@ -347,6 +352,12 @@ fn kind_method_registries(lua: &Lua, this: &Table) -> &'static [&'static str] {
|
||||
Some(FrameKind::Slider) => &[super::slider::REG_SLIDER_METHODS],
|
||||
Some(FrameKind::ColorSelect) => &[super::colorselect::REG_COLORSELECT_METHODS],
|
||||
Some(FrameKind::Button) => &[super::button::REG_BUTTON_METHODS],
|
||||
// One method of its own, then all of Button's — the miss order of `0x4c1be0`, which probes
|
||||
// its own 1-entry map `0xb71b64` and tail-calls `Button`'s `0x782c90`.
|
||||
Some(FrameKind::LootButton) => &[
|
||||
super::loot::REG_LOOTBUTTON_METHODS,
|
||||
super::button::REG_BUTTON_METHODS,
|
||||
],
|
||||
Some(FrameKind::CheckButton) => &[
|
||||
super::button::REG_CHECKBUTTON_METHODS,
|
||||
super::button::REG_BUTTON_METHODS,
|
||||
|
||||
@@ -117,6 +117,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
FrameKind::Frame => &["Frame", "Region"],
|
||||
FrameKind::Button => &["Button", "Frame", "Region"],
|
||||
FrameKind::CheckButton => &["CheckButton", "Button", "Frame", "Region"],
|
||||
// `CLootButton::IsObjectType 0x495af0` prepends its own name to the base's three
|
||||
// (`"Button"` `[0x879954]`, `"Frame"` `[0x878560]`, `"Region"` `[0x878870]`) — read
|
||||
// off the function, not assumed from the class hierarchy.
|
||||
FrameKind::LootButton => &["LootButton", "Button", "Frame", "Region"],
|
||||
FrameKind::EditBox => &["EditBox", "Frame", "Region"],
|
||||
FrameKind::StatusBar => &["StatusBar", "Frame", "Region"],
|
||||
FrameKind::Slider => &["Slider", "Frame", "Region"],
|
||||
|
||||
@@ -702,7 +702,7 @@ impl UiScript {
|
||||
if let Some(id) = click_id {
|
||||
// One click home for the physical and programmatic (`Click()`) paths: gated on a
|
||||
// Button's enabled flag, a CheckButton toggles before OnClick fires (button.rs).
|
||||
button::click_button(&self.lua, id, button, down);
|
||||
button::click_button(&self.lua, id, button, down, false);
|
||||
}
|
||||
// `OnDoubleClick(self, button)` — fired where `OnClick` would have been and instead of it
|
||||
// (`0x77938d call [edx+0x98]`, the mouse-UP dispatcher's exclusive second leg). One
|
||||
|
||||
@@ -587,6 +587,36 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
// UnitHasRelicSlot(unit) → **the number 1, or nil** (`0x519e50`). Whether this unit's
|
||||
// INVSLOT 17 holds a Libram/Totem/Idol instead of a bow.
|
||||
//
|
||||
// The whole body is: typemask bit 4 (PLAYER) → `UNIT_FIELD_BYTES_0` byte 1 (the class) →
|
||||
// bounds against `ds:0xc0def8` → `ds:0xc0def4`[class] → `[row+0x40]`, `ChrClasses.dbc` field
|
||||
// 16. There is **no `cmp` against a class id anywhere in the function** — 1.12 is entirely
|
||||
// data-driven here, and `benilla_formats::ChrClasses` is that data. The app resolves it and
|
||||
// hands us the bit, exactly as it does for `class_file`.
|
||||
//
|
||||
// **This shipped absent for a long time, on a claim that was simply false** — that the relic
|
||||
// slot post-dates 1.12 and `UnitHasRelicSlot` is a later-era verb. The base `dbc.MPQ` copy of
|
||||
// `ChrClasses.dbc` really does have no field 16; the `patch.MPQ` copy the client actually
|
||||
// reads has it, set for Paladin, Shaman and Druid. Decision 1785. Stock `PaperDollFrame.lua`
|
||||
// calls this **unconditionally** at l.429 and l.580 — so while it was missing, the character
|
||||
// sheet raised `attempt to call global 'UnitHasRelicSlot' (a nil value)` for *every* class,
|
||||
// not just the three.
|
||||
//
|
||||
// No `"player"` fast path (unlike `UnitClass 0x5183b0`, which has one), so this answers for
|
||||
// any token — `UnitHasRelicSlot("target")` on an enemy druid is 1, and the stock inspect path
|
||||
// at `PaperDollFrame.lua:429` depends on exactly that.
|
||||
//
|
||||
// The truthy leg is the NUMBER 1, never a boolean; the false leg is nil, never `false`.
|
||||
g.set(
|
||||
"UnitHasRelicSlot",
|
||||
lua.create_function(|lua, token: Option<String>| {
|
||||
let has = with_unit(lua, &token, false, |u| u.has_relic_slot)?;
|
||||
Ok(if has { Value::Integer(1) } else { Value::Nil })
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// UnitSex(unit) → 2 male, 3 female (1 = neuter — no 1.12 unit feed produces it); nil when the
|
||||
// unit is absent or the sex hasn't streamed (`0`), the API's "can't tell".
|
||||
g.set(
|
||||
|
||||
@@ -163,6 +163,18 @@ pub struct UnitState {
|
||||
/// the ref's stat-tooltip lookups key on (`getglobal(strupper(class).."_STAT_TOOLTIP")` —
|
||||
/// the ref uppercases it again defensively; we store it already uppercase).
|
||||
pub class_file: Option<String>,
|
||||
/// This unit's INVSLOT 17 is a **relic** slot rather than a ranged-weapon slot —
|
||||
/// `UnitHasRelicSlot`'s whole answer (`0x519e50`).
|
||||
///
|
||||
/// Resolved app-side off `ChrClasses.dbc` field 16, for the same reason [`Self::class_file`]
|
||||
/// is resolved app-side: it is a static per-class column of a table this crate cannot read.
|
||||
/// The reference gates on TYPEMASK_PLAYER before it looks at the class byte, so the feed sets
|
||||
/// this only for a player — a creature, a pet and an unstreamed unit all leave it `false`,
|
||||
/// which is the nil leg.
|
||||
///
|
||||
/// True for exactly Paladin, Shaman and Druid in 1.12 (Libram, Totem, Idol). It was believed
|
||||
/// here for a long time that no 1.12 class had one; see decision 1785 for how that survived.
|
||||
pub has_relic_slot: bool,
|
||||
/// The unit's sex on the `UnitSex` scale: `2` male, `3` female (`1` = neuter/unknown). `0`
|
||||
/// (the unfilled default) reports as nil — the API's "can't tell", like [`Self::reaction`].
|
||||
/// The app maps `UNIT_FIELD_BYTES_0` byte 2 (0 male / 1 female) onto this shape.
|
||||
|
||||
@@ -1291,3 +1291,56 @@ fn unit_is_party_leader_ors_two_legs_and_answers_one_when_solo() {
|
||||
// A bad token still raises through the shared resolver — shape C with a shape-A tail.
|
||||
assert!(s.run(r#"UnitIsPartyLeader("notatoken")"#).is_err());
|
||||
}
|
||||
|
||||
/// `UnitHasRelicSlot` — the number 1 or nil, per token, never a boolean.
|
||||
///
|
||||
/// This shipped **absent** for months on the belief that the relic slot post-dates 1.12, which is
|
||||
/// false (decision 1785). Stock `PaperDollFrame.lua` calls it unconditionally at l.429 and l.580,
|
||||
/// so while it was missing the character sheet raised `attempt to call global` for every class —
|
||||
/// which is why the nil-global case is asserted here too, not just the answer.
|
||||
#[test]
|
||||
fn unit_has_relic_slot_answers_one_or_nil() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
let mut druid = player();
|
||||
druid.class = Some("Druid".into());
|
||||
druid.class_file = Some("DRUID".into());
|
||||
druid.has_relic_slot = true;
|
||||
s.set_unit("player", Some(druid));
|
||||
|
||||
let mut warrior = player();
|
||||
warrior.has_relic_slot = false;
|
||||
s.set_unit("target", Some(warrior));
|
||||
|
||||
// The truthy leg is the NUMBER 1 (`lua_pushnumber`, `0x519ec8`) — a caller comparing it to 1
|
||||
// is stock idiom, and a boolean would silently fail that.
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return tostring(UnitHasRelicSlot("player"))"#)
|
||||
.unwrap(),
|
||||
"1"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return type(UnitHasRelicSlot("player"))"#)
|
||||
.unwrap(),
|
||||
"number"
|
||||
);
|
||||
// The false leg is nil, never `false` (`lua_pushnil`, `0x519edb`).
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return type(UnitHasRelicSlot("target"))"#)
|
||||
.unwrap(),
|
||||
"nil"
|
||||
);
|
||||
// No `"player"` fast path in the reference, so every token is answered the same way — the
|
||||
// stock inspect path at `PaperDollFrame.lua:429` passes the inspected unit, not "player".
|
||||
assert_eq!(
|
||||
s.eval::<String>(r#"return type(UnitHasRelicSlot("party1"))"#)
|
||||
.unwrap(),
|
||||
"nil"
|
||||
);
|
||||
// And it EXISTS — the regression that actually bit. A nil global here takes the whole
|
||||
// character sheet down for every class, not just the three that answer 1.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return type(UnitHasRelicSlot)").unwrap(),
|
||||
"function"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user