Files
benilla-octo/crates/benilla-ui/src/script/trade.rs
T
Sam SungandClaude Opus 5.5 78a983b00e The VM speaks 1.12 alone: the Era globals and the uncalled hooks are gone, the engine's tooltip seams are Rust calls, strata/draw-layer/state-texture/sound/OnClick/quest-item/pickup take their 1.12 shapes, and the surface gate covers the production load (#573)
The Lua VM keeps no surface the 1.12.1 client lacks.

- **Removed:** the nine Era-only globals: `UnitAura`, `CancelUnitBuff`, `GetCursorInfo`, `GetInventoryItemID`, `GetPlayerFacing`, `GetTradePartnerName`, `IsGossipOptionCoded`, `UnitIsAFK`, `UnitIsDND`.
- **Hooks off Lua:** `GameTooltip:BenillaSetItemById` and `:BenillaGetTooltipOwner` are replaced by Rust-side calls. The uncalled `Benilla*` hooks are deleted.
- **Frame strata:** `SetFrameStrata` knows the reference's eight strata and raises its message for any other (`0x774450`).
- **`SetDrawLayer`** reads the layer name alone (`0x79a780`).
- **`Set<State>Texture`** reads a number as a path (`0x781b23`); there is no colour form.
- **`PlaySound`** names a kit and returns nothing (`0x4586d0`). `PlaySoundFile` returns one value (`0x458780`); benilla answers 1, a recorded deviation, since it plays after the call.
- **`OnClick`** sets only `arg1` (`0x779540`).
- **Quest getters:** `GetQuestItemInfo` and the quest-log reward and choice getters answer the reference's five values and its miss behaviour (`0x501f80`, `0x4e0b00`, `0x4e0cf0`).
- **`PickupContainerItem`** returns nothing (`0x4f9b30`).
- **Surface gates:** the bare VM's globals and every widget class's methods are held to `reference/1.12-globals.tsv` with one commented allowlist. A new gate on the full production load (core plus layer) allows beyond that only the layer's `Benilla`-prefixed globals.

Verified:
- A test per removal and per corrected form; a scripted undo of each fails its test.
- Each gate fails on a re-added name.
- `scripts/gates.sh` and `scripts/smoke.sh` are green.
- A live run showed the same warnings and 0 script errors before and after.
- In the 219-addon corpus, AckisRecipeList's bundled AceGUI-3.0 and Roid-Macros' Grid extension now behave as they do on 1.12.1.

Built on macOS; no platform seam touched.

Fixes #171

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:44:52 +07:00

626 lines
22 KiB
Rust

//! The trade bindings: the app pushes both sides of the open trade ([`UiScript::set_trade`]), and
//! the trade verbs queue intents it drains. Slots are 1-based, 1..=7, the seventh the non-traded
//! enchant slot; an empty or out-of-range slot answers nil. The partner rides the `"npc"` unit,
//! which the app points at them.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::cursor::{self, CursorPayload};
use super::Model;
/// Slots per side, six traded and the non-traded enchant slot: vmangos `TRADE_SLOT_COUNT`
/// (`TradeData.h`), kept equal to the protocol crate's, which this crate does not depend on.
pub const TRADE_SLOTS: usize = 7;
/// One filled trade slot, resolved by the app; an empty slot is `None`, never a zeroed `Some`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TradeSlotItem {
/// The item entry, the key for `isUsable` and the tooltip.
pub item_id: u32,
/// `None` while the item template is in flight.
pub name: Option<String>,
pub texture: Option<String>,
pub count: u32,
/// `None` while the template is in flight; only the target side's info returns it
/// (`TradeFrame.lua:84`).
pub quality: Option<u32>,
/// The name of the enchant being applied to slot 7, which stock shows in green
/// (`TradeFrame.lua:62`); the app always sends `None`.
pub enchantment: Option<String>,
/// The escaped item link the link verbs answer; `None` while the template is in flight, since
/// it embeds the name and the quality.
pub link: Option<String>,
}
/// One side's offer.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TradeSideState {
/// Index 0 is trade slot 1, index 6 the enchant slot.
pub slots: [Option<TradeSlotItem>; TRADE_SLOTS],
/// In copper.
pub gold: u32,
}
/// The open trade, pushed whole by the app. The accept highlight is not here: it rides the
/// `TRADE_ACCEPT_UPDATE` event (`TradeFrame.lua:35`).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TradeState {
/// Our offer.
pub player: TradeSideState,
/// The partner's offer, the wire's `their_window` snapshot.
pub target: TradeSideState,
}
impl super::UiScript {
/// Push (or clear, with `None`) the open trade window's snapshot.
pub fn set_trade(&mut self, state: Option<TradeState>) {
self.model_mut().trade = state;
}
/// Drain the tokens `InitiateTrade` queued; the app sends `CMSG_INITIATE_TRADE` for each.
pub fn take_trade_initiates(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().trade_initiates)
}
/// Whether `AcceptTrade` was called since the last drain: `CMSG_ACCEPT_TRADE`.
pub fn take_trade_accept(&mut self) -> bool {
std::mem::take(&mut self.model_mut().trade_accept)
}
/// Whether `CancelTradeAccept` was called since the last drain: `CMSG_UNACCEPT_TRADE`.
pub fn take_trade_unaccept(&mut self) -> bool {
std::mem::take(&mut self.model_mut().trade_unaccept)
}
/// Whether `CloseTrade`, the window's OnHide verb, was called since the last drain; the app
/// sends `CMSG_CANCEL_TRADE` and ends its session.
pub fn take_trade_close(&mut self) -> bool {
std::mem::take(&mut self.model_mut().trade_close)
}
/// Whether `BeginTrade()` was called since the last drain: an empty `CMSG_BEGIN_TRADE`.
pub fn take_trade_begin(&mut self) -> bool {
std::mem::take(&mut self.model_mut().trade_begin)
}
/// Whether `CancelTrade()` was called since the last drain: the bare `CMSG_CANCEL_TRADE`.
pub fn take_trade_cancel(&mut self) -> bool {
std::mem::take(&mut self.model_mut().trade_cancel)
}
/// The copper `SetTradeMoney` last offered since the last drain: `CMSG_SET_TRADE_GOLD`.
pub fn take_trade_money(&mut self) -> Option<u32> {
std::mem::take(&mut self.model_mut().trade_set_money)
}
/// The `(trade_id, bag, slot)` placements `ClickTradeButton` queued, for `CMSG_SET_TRADE_ITEM`:
/// `trade_id` 1..=7, `bag` and `slot` in the cursor's space.
pub fn take_trade_set_items(&mut self) -> Vec<(u32, i64, u32)> {
std::mem::take(&mut self.model_mut().trade_set_items)
}
/// The 1-based slots `ClickTradeButton` queued to clear, for `CMSG_CLEAR_TRADE_ITEM`.
pub fn take_trade_clear_items(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().trade_clear_items)
}
}
/// The slot at a 1-based index on the side `pick` selects.
fn slot_at(
model: &Model,
index: usize,
pick: impl Fn(&TradeState) -> &TradeSideState,
) -> Option<TradeSlotItem> {
model
.trade
.as_ref()
.map(pick)
.and_then(|side| index.checked_sub(1).and_then(|n| side.slots.get(n)))
.cloned()
.flatten()
}
fn gold(model: &Model, pick: impl Fn(&TradeState) -> &TradeSideState) -> u32 {
model.trade.as_ref().map(pick).map_or(0, |side| side.gold)
}
/// `ClickTradeButton(id)` on our slot (`TradeFrame.xml:135`): a held bag item is queued for
/// `CMSG_SET_TRADE_ITEM` and the cursor cleared, the app filling our column itself since vmangos
/// echoes a placement only to the partner (`TradeData.cpp:81`); an empty cursor on a filled slot
/// queues a clear. Any other payload stays on the cursor.
fn click_trade_button(model: &mut Model, id: u32) {
// Coins on the cursor go into the offer, the index unread (`0x4bfe34`).
if matches!(model.cursor, Some(CursorPayload::Money(_))) {
cursor::money::add_trade_money(model);
return;
}
match model.cursor.take() {
Some(CursorPayload::Item(item)) => {
let (bag, slot) = (item.bag, item.slot);
model.trade_set_items.push((id, bag, slot));
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
}
None => {
let filled = matches!(
id.checked_sub(1).and_then(|i| {
model
.trade
.as_ref()
.and_then(|t| t.player.slots.get(i as usize))
}),
Some(Some(_))
);
if filled {
model.trade_clear_items.push(id);
}
}
Some(other) => model.cursor = Some(other),
}
}
/// Register the trade globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// GetTradePlayerItemInfo(id): the five values `TradeFrame.lua:55` reads.
g.set(
"GetTradePlayerItemInfo",
lua.create_function(|lua, id: usize| {
let (slot, usable) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let slot = slot_at(&model, id, |t| &t.player);
let usable = slot
.as_ref()
.is_none_or(|s| super::item_stats::item_usable_by_id(&model, s.item_id));
(slot, usable)
};
let Some(s) = slot else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
let opt_str = |v: &Option<String>| -> mlua::Result<Value> {
Ok(match v {
Some(s) => Value::String(lua.create_string(s)?),
None => Value::Nil,
})
};
Ok(MultiValue::from_vec(vec![
opt_str(&s.name)?,
opt_str(&s.texture)?,
Value::Integer(i64::from(s.count.max(1))),
flag(usable),
opt_str(&s.enchantment)?,
]))
})?,
)?;
// The link verbs, for a slot's ctrl and shift clicks (`TradeFrame.xml:129`, `:95`).
for (name, side) in [
("GetTradePlayerItemLink", true),
("GetTradeTargetItemLink", false),
] {
g.set(
name,
lua.create_function(move |lua, id: usize| {
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let slot = if side {
slot_at(&model, id, |t| &t.player)
} else {
slot_at(&model, id, |t| &t.target)
};
slot.and_then(|s| s.link.clone())
};
match link {
Some(link) => Ok(Value::String(lua.create_string(&link)?)),
None => Ok(Value::Nil),
}
})?,
)?;
}
// GetTradeTargetItemInfo(id): the six values `TradeFrame.lua:84` reads.
g.set(
"GetTradeTargetItemInfo",
lua.create_function(|lua, id: usize| {
let (slot, usable) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let slot = slot_at(&model, id, |t| &t.target);
let usable = slot
.as_ref()
.is_none_or(|s| super::item_stats::item_usable_by_id(&model, s.item_id));
(slot, usable)
};
let Some(s) = slot else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
let opt_str = |v: &Option<String>| -> mlua::Result<Value> {
Ok(match v {
Some(s) => Value::String(lua.create_string(s)?),
None => Value::Nil,
})
};
let quality = match s.quality {
Some(q) => Value::Integer(i64::from(q)),
None => Value::Nil,
};
Ok(MultiValue::from_vec(vec![
opt_str(&s.name)?,
opt_str(&s.texture)?,
Value::Integer(i64::from(s.count.max(1))),
quality,
flag(usable),
opt_str(&s.enchantment)?,
]))
})?,
)?;
g.set(
"GetPlayerTradeMoney",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(gold(&model, |t| &t.player)))
})?,
)?;
g.set(
"GetTargetTradeMoney",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(i64::from(gold(&model, |t| &t.target)))
})?,
)?;
// InitiateTrade(unit): the unit menu's TRADE row.
g.set(
"InitiateTrade",
lua.create_function(|lua, unit: String| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.trade_initiates
.push(unit);
Ok(())
})?,
)?;
g.set(
"AcceptTrade",
lua.create_function(|lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.trade_accept = true;
Ok(())
})?,
)?;
g.set(
"CancelTradeAccept",
lua.create_function(|lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.trade_unaccept = true;
Ok(())
})?,
)?;
g.set(
"CloseTrade",
lua.create_function(|lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.trade_close = true;
Ok(())
})?,
)?;
// BeginTrade() and CancelTrade() (`0x48aa60`, `0x48aa70`): no arguments, no returns, no gate,
// and an empty `0x117` or `0x11C`. They are the `TRADE` popup's Yes and No
// (`StaticPopup.lua:517-522`), which never shows in 1.12 as nothing signals `TRADE_REQUEST`.
g.set(
"BeginTrade",
lua.create_function(|lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.trade_begin = true;
Ok(())
})?,
)?;
g.set(
"CancelTrade",
lua.create_function(|lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.trade_cancel = true;
Ok(())
})?,
)?;
// SetTradeMoney(copper): the money input's callback (`TradeFrame.lua:173`).
g.set(
"SetTradeMoney",
lua.create_function(|lua, copper: Value| {
// `0x4c0820`: a non-number raises; the low dword is the whole offer, sent only when the
// purse covers it, and a refusal is silent.
let n = crate::script::binding_abi::number_arg(
lua,
copper,
"Usage: SetTradeMoney(amount)",
)? as u32;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if u64::from(n) > model.money {
return Ok(());
}
model.trade_set_money = Some(n);
Ok(())
})?,
)?;
g.set(
"ClickTradeButton",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
click_trade_button(&mut model, id);
Ok(())
})?,
)?;
// ClickTargetTradeButton(id) (`TradeFrame.xml:101`): the partner's column takes nothing.
g.set(
"ClickTargetTradeButton",
lua.create_function(|lua, _id: u32| {
// Coins on the cursor still go into our offer, the index unread (`0x4c00a3`).
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
cursor::money::add_trade_money(&mut model);
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::script::UiScript;
fn item(item_id: u32, count: u32, quality: u32) -> TradeSlotItem {
TradeSlotItem {
item_id,
name: Some("Linen Cloth".into()),
texture: Some("Interface\\Icons\\INV_Fabric_Linen_01".into()),
count,
quality: Some(quality),
enchantment: None,
link: Some(format!(
"|cffffffff|Hitem:{item_id}:0:0:0|h[Linen Cloth]|h|r"
)),
}
}
fn state() -> TradeState {
let mut player = TradeSideState {
gold: 12_345,
..Default::default()
};
player.slots[0] = Some(item(2589, 5, 1));
let mut target = TradeSideState {
gold: 500,
..Default::default()
};
target.slots[0] = Some(item(4306, 1, 2));
target.slots[6] = Some(item(6217, 1, 1)); // the enchant slot carries an item
TradeState { player, target }
}
#[test]
fn the_trade_slots_answer_their_item_links() {
let mut s = UiScript::new().unwrap();
// No trade open: both sides answer nil rather than raising.
assert!(s
.eval::<bool>("return GetTradePlayerItemLink(1) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetTradeTargetItemLink(1) == nil")
.unwrap());
let mut st = state();
// A slot whose template is in flight has no link.
st.target.slots[1] = Some(TradeSlotItem {
item_id: 4306,
name: None,
texture: None,
count: 1,
quality: None,
enchantment: None,
link: None,
});
s.set_trade(Some(st));
assert_eq!(
s.eval::<String>("return GetTradePlayerItemLink(1)")
.unwrap(),
"|cffffffff|Hitem:2589:0:0:0|h[Linen Cloth]|h|r"
);
assert_eq!(
s.eval::<String>("return GetTradeTargetItemLink(1)")
.unwrap(),
"|cffffffff|Hitem:4306:0:0:0|h[Linen Cloth]|h|r"
);
// The in-flight slot, an empty one, and one past the seven.
assert!(s
.eval::<bool>("return GetTradeTargetItemLink(2) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetTradePlayerItemLink(7) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetTradePlayerItemLink(9) == nil")
.unwrap());
}
#[test]
fn player_and_target_item_info_read_the_reference_tuples() {
let mut s = UiScript::new().unwrap();
// No trade open → every slot is nil, money 0.
assert!(s
.eval::<bool>("return GetTradePlayerItemInfo(1) == nil")
.unwrap());
assert_eq!(s.eval::<i64>("return GetPlayerTradeMoney()").unwrap(), 0);
s.set_trade(Some(state()));
let (name, tex, count, usable): (String, String, i64, i64) = s
.eval(
"local n,t,c,u,e = GetTradePlayerItemInfo(1)\n\
return n,t,c,u",
)
.unwrap();
assert_eq!((name.as_str(), count, usable), ("Linen Cloth", 5, 1));
assert_eq!(tex, "Interface\\Icons\\INV_Fabric_Linen_01");
assert!(s
.eval::<bool>("local n,t,c,u,e = GetTradePlayerItemInfo(1)\nreturn e == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetTradePlayerItemInfo(2) == nil")
.unwrap());
// The target side adds quality as the fourth return.
let (name, _tex, count, quality, usable): (String, String, i64, i64, i64) =
s.eval("return GetTradeTargetItemInfo(1)").unwrap();
assert_eq!(
(name.as_str(), count, quality, usable),
("Linen Cloth", 1, 2, 1)
);
// The enchant slot (7) is filled on the target side.
assert!(s
.eval::<bool>("return GetTradeTargetItemInfo(7) ~= nil")
.unwrap());
}
#[test]
fn money_reads_the_pushed_state() {
let mut s = UiScript::new().unwrap();
s.set_trade(Some(state()));
assert_eq!(
s.eval::<i64>("return GetPlayerTradeMoney()").unwrap(),
12_345
);
assert_eq!(s.eval::<i64>("return GetTargetTradeMoney()").unwrap(), 500);
}
#[test]
fn clearing_the_trade_empties_it() {
let mut s = UiScript::new().unwrap();
s.set_trade(Some(state()));
s.set_trade(None);
assert!(s
.eval::<bool>("return GetTradePlayerItemInfo(1) == nil")
.unwrap());
assert_eq!(s.eval::<i64>("return GetTargetTradeMoney()").unwrap(), 0);
}
#[test]
fn intents_queue_and_drain() {
let mut s = UiScript::new().unwrap();
s.run("InitiateTrade('target')").unwrap();
s.run("InitiateTrade('party2')").unwrap();
assert_eq!(s.take_trade_initiates(), vec!["target", "party2"]);
assert!(s.take_trade_initiates().is_empty(), "drained");
s.run("AcceptTrade()").unwrap();
assert!(s.take_trade_accept());
assert!(!s.take_trade_accept(), "drained");
s.run("CancelTradeAccept()").unwrap();
assert!(s.take_trade_unaccept());
s.run("CloseTrade()").unwrap();
assert!(s.take_trade_close());
assert!(!s.take_trade_close(), "drained");
// An offer is sent only when the purse covers it.
s.set_money(20_000);
s.run("SetTradeMoney(1 * 10000 + 23 * 100 + 45)").unwrap();
assert_eq!(s.take_trade_money(), Some(12_345));
assert_eq!(s.take_trade_money(), None, "drained");
// The low dword of -5 is 0xFFFFFFFB, past any purse: refused silently.
s.run("SetTradeMoney(-5)").unwrap();
assert_eq!(s.take_trade_money(), None);
s.run("SetTradeMoney(30000)").unwrap();
assert_eq!(s.take_trade_money(), None, "more than the purse: silent");
assert!(s.run("SetTradeMoney(nil)").is_err(), "a non-number raises");
}
#[test]
fn click_trade_button_places_clears_and_refuses() {
use crate::script::cursor::{CursorItem, CursorPayload};
let item = |bag: i64, slot: u32| {
CursorPayload::Item(CursorItem {
bar_placeable: true,
bag,
slot,
item_id: 2589,
texture: None,
link: None,
count: None,
quality: None,
equip_slots: Vec::new(),
})
};
let mut s = UiScript::new().unwrap();
// Empty cursor, no trade → nothing queued.
s.run("ClickTradeButton(1)").unwrap();
assert!(s.take_trade_set_items().is_empty());
assert!(s.take_trade_clear_items().is_empty());
// A held backpack item dropped on our slot 2 queues (2, 0, 3) and clears the cursor.
s.model_mut().cursor = Some(item(0, 3));
s.run("ClickTradeButton(2)").unwrap();
assert_eq!(s.take_trade_set_items(), vec![(2, 0, 3)]);
assert!(
s.eval::<bool>("return not CursorHasItem()").unwrap(),
"the drop clears the cursor"
);
// An empty cursor clears a filled slot and does nothing on an empty one.
let mut st = TradeState::default();
st.player.slots[0] = Some(TradeSlotItem {
item_id: 2589,
count: 1,
..Default::default()
});
s.set_trade(Some(st));
s.run("ClickTradeButton(1)").unwrap();
assert_eq!(s.take_trade_clear_items(), vec![1]);
s.run("ClickTradeButton(4)").unwrap();
assert!(
s.take_trade_clear_items().is_empty(),
"an empty slot clears nothing"
);
// The partner's column queues nothing and leaves the cursor alone.
s.model_mut().cursor = Some(item(0, 3));
s.run("ClickTargetTradeButton(1)").unwrap();
assert!(s.take_trade_set_items().is_empty());
assert!(
s.eval::<bool>("return CursorHasItem()").unwrap(),
"a partner-side click leaves the cursor untouched"
);
// A spell payload is refused and stays on the cursor.
s.model_mut().cursor = Some(CursorPayload::Spell(crate::script::cursor::CursorSpell {
passive: false,
book_slot: 1,
book_type: "spell".into(),
spell_id: 133,
texture: None,
}));
s.run("ClickTradeButton(1)").unwrap();
assert!(s.take_trade_set_items().is_empty());
assert!(
s.eval::<bool>("return CursorHasSpell()").unwrap(),
"a spell cursor is refused, left in place"
);
}
}