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:
Sam
2026-09-02 17:00:40 +07:00
co-authored by Claude Fable 5.1
parent d017ca1cde
commit ed524ab184
59 changed files with 2084 additions and 1231 deletions
+67 -2
View File
@@ -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);
}
}
+8 -3
View File
@@ -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| {
+25
View File
@@ -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(_)
+46 -1
View File
@@ -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(())
})?,
)?;
+240 -1
View File
@@ -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}");
}
}
+243
View File
@@ -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)]);
}
}
+5 -2
View File
@@ -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",
+8
View File
@@ -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,
+11
View File
@@ -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"],
+1 -1
View File
@@ -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(
+12
View File
@@ -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"
);
}