Files
benilla-octo/crates/benilla-app/src/ui_items/drain.rs
T
Sam SungandClaude Opus 5.5 bb82dd1ac8 Lua getters answer the reference's miss shapes; tooltip setters' flag (#1030)
Lua bindings answer the reference's shapes on misses, bad indices and tooltips:

- Indexed getters push their full miss tuple (`GetCraftInfo`, `GetPetActionInfo`, the trainer requirements and others). Indices go through `ftol` and an unsigned compare, so a negative misses instead of raising. Verified slot readers keep their own raises (`IsInventoryItemLocked`, `0x4c8520`).
- The eight GameTooltip setters return the builder's refresh flag, gated as `0x53267c` gates it, so a hovered cooldown tooltip refreshes. `LOCKED` follows the item object (`0x52bdb3`).
- Quality goes through the InventoryType gate (-1), and a pending template shows the question-mark icon.
- `GetLootSlotInfo` returns four values. `SetSpell`, `CreateMacro`, `JoinChannelByName`, `GetQuestReward` and the shapeshift and trade-skill filters raise or answer as the reference does.
- The shape gate probes each getter's miss legs through an argument ladder.

Fixes #145
Fixes #993
Fixes #994
Fixes #995
Fixes #996
Fixes #997
Fixes #998
Fixes #1016

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:28:35 +07:00

1857 lines
72 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The per-frame drains that send the queued Lua container intents as `ClientCommand`s, locking
//! the slots each send touches in [`crate::pending_item_ops::PendingItemOps`].
use bevy::prelude::*;
use benilla_protocol::messages::BAG_PLAYER_INVENTORY;
use benilla_ui::script::{UiScript, EQUIPMENT_BAG};
use crate::items::Items;
use crate::net::{ClientCommand, NetCommands, ObjectStore, Objects, SelfPlayer};
use crate::pending_item_ops::PendingItemOps;
use super::{slot_guid, slot_guid_count, wire_pos, INVTYPE_AMMO};
/// The one auto-equip sender, as in the reference (`AutoEquipCursorItem`, `0x5e1480`); answers
/// whether it sent. An ammo item loads by entry with `CMSG_SET_AMMO` and stays in its bag. An
/// unbound, equippable `bonding == 2` item raises `AUTOEQUIP_BIND_CONFIRM` and sends nothing
/// (`0x5e163b`) unless `suppress`, the reference's flag, which the accept's re-issue sets.
pub(crate) fn send_auto_equip(
script: &mut UiScript,
gate: &mut crate::ui_bind_confirm::BindGate,
objects: &Objects,
items: &Items,
commands: &NetCommands,
bag_index: u8,
slot: u8,
guid: Option<u64>,
suppress: bool,
) -> bool {
if !suppress {
if let Some(guid) = guid {
if gate.equip_binds(script, objects, items, commands, guid) {
gate.defer_equip(
script,
crate::ui_bind_confirm::PendingEquip::AutoEquip {
bag_index,
slot,
guid,
},
);
return false;
}
}
}
let ammo_entry = guid.and_then(|guid| {
let entry = objects.object(guid)?.object_entry()?;
let t = items.template(entry, guid, commands)?;
(t.inventory_type == INVTYPE_AMMO).then_some(entry)
});
if let Some(entry) = ammo_entry {
debug!("ui_items: set ammo entry {entry} (wire {bag_index}/{slot})");
let _ = commands.0.send(ClientCommand::SetAmmo { entry });
} else {
debug!("ui_items: autoequip wire {bag_index}/{slot}");
let _ = commands
.0
.send(ClientCommand::AutoEquipItem { bag_index, slot });
}
true
}
/// Sends the auto-equips `AutoEquipCursorItem` queued through [`send_auto_equip`]. It takes no
/// pending lock, so the source slot does not dim while the equip is in flight.
pub(super) fn drain_container_autoequips(
script: Option<NonSendMut<UiScript>>,
items: Res<Items>,
objects: Objects,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
commands: Res<NetCommands>,
mut gate: crate::ui_bind_confirm::BindGate,
) {
let Some(mut script) = script else {
return;
};
for (bag, slot) in script.take_container_autoequips() {
let Some((bag_index, wire_slot)) = wire_pos(bag, slot) else {
debug!("ui_items: autoequip ({bag}, {slot}) out of range — ignored");
continue;
};
let slot0 = u8::try_from(slot.saturating_sub(1)).unwrap_or(0);
let guid = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, bag, slot0, &objects));
send_auto_equip(
&mut script,
&mut gate,
&objects,
&items,
&commands,
bag_index,
wire_slot,
guid,
false,
);
}
}
/// Sends the auto-stores `PutItemInBag`/`PutItemInBackpack` queued. The destination is a bag, not
/// a slot (`0x4c7c00` to `0x5e12e0`): `CMSG_AUTOSTORE_BAG_ITEM` names `(srcbag, srcslot, dstbag)`
/// and the server picks the slot. A split carry sends `CMSG_SPLIT_ITEM` instead (the reference's
/// fork on `[0xb4b40c]`). Takes no pending lock.
pub(super) fn drain_bag_autostores(
script: Option<NonSendMut<UiScript>>,
commands: Res<NetCommands>,
) {
let Some(mut script) = script else {
return;
};
for a in script.take_bag_autostores() {
let (Some((src_bag, src_slot)), Some((dst_bag, _))) =
(wire_pos(a.src_bag, a.src_slot), wire_pos(a.dst_bag, 1))
else {
debug!("ui_items: bag autostore {a:?} out of range — ignored");
continue;
};
match a.count {
None => {
debug!(
"ui_items: autostore lua {}/{} → bag {} (wire {src_bag}/{src_slot} → {dst_bag})",
a.src_bag, a.src_slot, a.dst_bag
);
let _ = commands.0.send(ClientCommand::AutoStoreBagItem {
src_bag,
src_slot,
dst_bag,
});
}
Some(n) => {
let count = n.min(u32::from(u8::MAX)) as u8;
debug!(
"ui_items: autostore split lua {}/{} → bag {} × {count} (wire {src_bag}/{src_slot} → {dst_bag})",
a.src_bag, a.src_slot, a.dst_bag
);
let _ = commands.0.send(ClientCommand::SplitItem {
src_bag,
src_slot,
dst_bag,
// The reference's literal for no destination slot.
dst_slot: 0xFF,
count,
});
}
}
}
}
/// What a `UseContainerItem` or `UseInventoryItem` call reads and writes.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct ScriptItemUse<'w, 's> {
self_q: Query<'w, 's, &'static ObjectStore, With<SelfPlayer>>,
merchant: Res<'w, crate::ui_merchant::MerchantOpen>,
bank: Res<'w, crate::ui_bank::BankOpen>,
equip_sound: MessageWriter<'w, crate::sound::AutoEquipSound>,
item_text: ResMut<'w, crate::ui_item_text::ItemTextOpen>,
targeting: crate::spell::cast_target::CastTargeting<'w, 's>,
pending_items: ResMut<'w, PendingItemOps>,
// The loot latch the open arm sets, so `SMSG_LOOT_RESPONSE` admits an item loot.
loot_latch: ResMut<'w, crate::ui_loot::LootLatch>,
ladder: crate::spell::CastLadder<'w, 's>,
ui_errors: ResMut<'w, crate::ui_action::UiErrorKeys>,
gate: crate::ui_bind_confirm::BindGate<'w>,
}
impl ScriptItemUse<'_, '_> {
/// `UseInventoryItem(id)`, a doll right-click, sent through [`super::send_item_use`], as the
/// reference's doll click reaches the same `CGItem::Use` a bag click does (`0x4c7af0`): a worn
/// quest-starter offers its quest. Ids outside 1..=19 are refused.
pub(crate) fn use_inventory_item(&mut self, script: &mut UiScript, id: u32) {
let Self {
self_q,
targeting,
ladder,
ui_errors,
gate,
..
} = self;
if !(1..=19).contains(&id) {
debug!("ui_items: UseInventoryItem({id}) out of range — ignored");
return;
}
let slot = (id - 1) as u8;
let (guid, start_quest, spell_index, use_spell, entry, is_charter) = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, EQUIPMENT_BAG, slot, &ladder.objects))
.and_then(|guid| {
let entry = ladder.objects.object(guid)?.object_entry()?;
let t = ladder.items.template(entry, guid, &ladder.commands)?;
Some((
Some(guid),
t.start_quest,
t.use_spell_index().unwrap_or(0),
t.use_spell.map(|u| u.spell_id),
Some(entry),
t.flags & benilla_protocol::messages::ITEM_FLAG_CHARTER != 0,
))
})
.unwrap_or((None, 0, 0, None, None, false));
debug!("ui_items: use equipped item, lua slot {id} (wire 255/{slot})");
super::send_item_use(
super::ItemUse {
guid,
start_quest,
bag_index: BAG_PLAYER_INVENTORY,
slot,
entry: entry.unwrap_or(0),
spell_index,
use_spell,
on_object: None,
is_charter,
},
&targeting.context(),
ladder,
script,
gate,
false,
ui_errors,
);
}
/// `UseContainerItem(bag, slot)` outside repair mode: a sale with a merchant open, a bank
/// move with the bank open, else the reference's equip-vs-use fork and use dispatcher.
pub(crate) fn use_container_item(&mut self, script: &mut UiScript, bag: i64, slot: u32) {
let Self {
self_q,
merchant,
bank,
equip_sound,
item_text,
targeting,
pending_items,
loot_latch,
ladder,
ui_errors,
gate,
} = self;
let slot0 = u8::try_from(slot.saturating_sub(1)).ok();
// With a merchant open, the click sells the whole stack (`CMSG_SELL_ITEM`, count 0).
if let (true, Some(vendor)) = (merchant.is_open(), merchant.vendor) {
let item_guid = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, bag, slot0.unwrap_or(0), &ladder.objects));
match item_guid {
Some(guid) => {
debug!("ui_items: sell lua bag {bag} slot {slot} (item {guid:#x})");
let _ = ladder.commands.0.send(ClientCommand::SellItem {
vendor,
item_guid: guid,
count: 0,
});
}
None => debug!("ui_items: sell on empty slot (bag {bag} slot {slot}) — ignored"),
}
return;
}
let Some((bag_index, wire_slot)) = wire_pos(bag, slot) else {
debug!("ui_items: UseContainerItem({bag}, {slot}) out of range — ignored");
return;
};
// With the bank open, a vault or bank-bag item withdraws (`CMSG_AUTOSTORE_BANK_ITEM`) and
// any other deposits (`CMSG_AUTOBANK_ITEM`): the reference's bank flag, set for bags -1
// and 5..10 (`0x4f9820`), picks the opcode (`0x4fa2f2`). Doll clicks come through
// `use_inventory_item`, so worn gear keeps its plain use.
if bank.is_open() {
let withdrawing = bag == super::BANK_CONTAINER || (5..=10).contains(&bag);
if withdrawing {
debug!("ui_items: withdraw (lua bag {bag} → wire {bag_index}/{wire_slot})");
let _ = ladder.commands.0.send(ClientCommand::AutoStoreBankItem {
bag: bag_index,
slot: wire_slot,
});
} else {
debug!("ui_items: deposit (lua bag {bag} → wire {bag_index}/{wire_slot})");
let _ = ladder.commands.0.send(ClientCommand::AutoBankItem {
bag: bag_index,
slot: wire_slot,
});
}
return;
}
// `None` (empty, or the template in flight) falls through to a plain use.
let clicked = self_q
.iter()
.next()
.and_then(|store| slot_guid(&store.0, bag, slot0.unwrap_or(0), &ladder.objects))
.and_then(|guid| {
let obj = ladder.objects.object(guid)?;
let inst_flags = obj.item_flags().unwrap_or(0);
let item_text_id = obj.item_text_id().unwrap_or(0);
let entry = obj.object_entry()?;
let t = ladder.items.template(entry, guid, &ladder.commands)?;
Some(Clicked {
guid,
entry,
item_text_id,
inventory_type: t.inventory_type,
display_info_id: t.display_info_id,
start_quest: t.start_quest,
spell_index: t.use_spell_index().unwrap_or(0),
use_spell: t.use_spell.map(|u| u.spell_id),
unwraps_gift: t.unwraps_gift(inst_flags),
begins_gift_wrap: t.begins_gift_wrap(inst_flags),
opens_loot: t.opens_loot(),
page_text: t.page_text,
is_charter: t.flags & benilla_protocol::messages::ITEM_FLAG_CHARTER != 0,
})
});
// The reference's equip-vs-use fork (`0x4fa3b9`): an equippable item auto-equips unless
// its `StartQuest` (`[rec+0x1a8]`) is set (`0x4fa3bd`). Everything below is the use
// dispatcher `0x5d8d00`, in its own order.
if let Some(c) = clicked.filter(|c| c.start_quest == 0 && c.inventory_type != 0) {
// This path never moves the cursor, so it plays the equip sound itself; a deferred
// equip plays none, as the reference's sound rides the arm that sends.
if send_auto_equip(
script,
gate,
&ladder.objects,
&ladder.items,
&ladder.commands,
bag_index,
wire_slot,
Some(c.guid),
false,
) {
equip_sound.write(crate::sound::AutoEquipSound {
display_id: c.display_info_id,
});
}
return;
}
// A wrapped gift unwraps with `CMSG_OPEN_ITEM` (`0x5d8d92`, emitter `0x5edd60`), first in
// the dispatcher; vmangos swaps the entry back from `character_gifts`.
if let Some(c) = clicked.filter(|c| c.unwraps_gift) {
debug!(
"ui_items: unwrap gift {:#x} (lua bag {bag} → wire {bag_index}/{wire_slot})",
c.guid
);
let _ = ladder.commands.0.send(ClientCommand::OpenItem {
bag_index,
slot: wire_slot,
});
return;
}
// Wrapping paper (`0x5d8d9d`'s other branch, `0x5edea0`) sends nothing: its slot locks and
// the cursor becomes mode 2 until a left-click on a container slot sends `CMSG_WRAP_ITEM`.
if let Some(c) = clicked.filter(|c| c.begins_gift_wrap) {
debug!(
"ui_items: arm gift wrap with {:#x} (lua bag {bag} slot {slot})",
c.guid
);
script.arm_gift_wrap(bag, slot);
return;
}
// A quest-starter (`0x5d8dd2`) offers its quest, ahead of the readable and open arms.
if let Some(c) = clicked.filter(|c| c.start_quest != 0) {
debug!(
"ui_items: quest-starter {:#x} offers quest {}",
c.guid, c.start_quest
);
super::send_item_use(
super::ItemUse {
guid: Some(c.guid),
start_quest: c.start_quest,
bag_index,
slot: wire_slot,
entry: c.entry,
spell_index: c.spell_index,
use_spell: c.use_spell,
on_object: None,
is_charter: c.is_charter,
},
&targeting.context(),
ladder,
script,
gate,
false,
ui_errors,
);
return;
}
// A book, a template with `PageText` (`0x5d8e4c`), opens the reader with no packet
// (`0x4e32e0`). It precedes the open arm, the inverse of the tooltip: an item both
// readable and lootable shows `<Right Click to Open>` and reads on click.
if let Some(c) = clicked.filter(|c| c.page_text != 0) {
if item_text.toggle_closed(c.guid) {
debug!("ui_items: re-click closes the book {:#x}", c.guid);
} else {
debug!(
"ui_items: read book {:#x} (page {}, lua bag {bag} slot {slot})",
c.guid, c.page_text
);
item_text.open_pages(c.guid);
}
return;
}
// A letter from mail (`ITEM_FIELD_ITEM_TEXT_ID`) opens the reader with no packet; vmangos's
// `CMSG_READ_ITEM` wants a template `PageText`, so the text comes by item-text query.
if let Some(c) = clicked.filter(|c| c.item_text_id != 0) {
if item_text.toggle_closed(c.guid) {
debug!("ui_items: re-click closes the letter {:#x}", c.guid);
} else {
debug!(
"ui_items: read letter {:#x} (text {}, lua bag {bag} slot {slot})",
c.guid, c.item_text_id
);
item_text.open_letter(c.guid, c.item_text_id);
}
return;
}
// A lootable template (`0x5d8f7c`, emitter `0x5edc80`) sends `CMSG_OPEN_ITEM`, which the
// server answers with a loot window on the item (`SpellHandler.cpp:227`). Unlike the
// tooltip line, the send tests neither the lock id nor the unlocked bit, so a locked box
// sends and the server's `EQUIP_ERR_ITEM_LOCKED` is the "Item is locked" line.
if let Some(c) = clicked.filter(|c| c.opens_loot) {
debug!(
"ui_items: open item {:#x} (lua bag {bag} → wire {bag_index}/{wire_slot})",
c.guid
);
// The loot latch on the item's guid, before the send (`0x5edcc0`); the admission gate
// refuses the type-1 `SMSG_LOOT_RESPONSE` without it. No kneel (`0x612710`).
loot_latch.0 = Some(c.guid);
// The grey lock, also before the send (lock setter `0x4953e0` at `0x5edcd9`); it holds
// until the item empties, the server refuses, or `SMSG_LOOT_RELEASE_RESPONSE` unlocks
// it by guid. The unwrap arm's emitter (`0x5edd60`) takes neither lock nor latch.
let (guid, count) = self_q
.iter()
.next()
.map(|store| slot_guid_count(Some(store), bag, slot, &ladder.objects))
.unwrap_or((0, 0));
pending_items.add([(bag, slot, guid, count)]);
script.fire_event("ITEM_LOCK_CHANGED", Vec::new());
let _ = ladder.commands.0.send(ClientCommand::OpenItem {
bag_index,
slot: wire_slot,
});
return;
}
// A plain use (food, potions, the hearthstone); quest-starters took the arm above.
debug!("ui_items: use item (lua bag {bag} → wire {bag_index}/{wire_slot})");
super::send_item_use(
super::ItemUse {
guid: clicked.map(|c| c.guid),
start_quest: 0,
bag_index,
slot: wire_slot,
entry: clicked.map_or(0, |c| c.entry),
spell_index: clicked.map_or(0, |c| c.spell_index),
use_spell: clicked.and_then(|c| c.use_spell),
on_object: None,
is_charter: clicked.is_some_and(|c| c.is_charter),
},
&targeting.context(),
ladder,
script,
gate,
false,
ui_errors,
);
}
}
/// Sends the repair-mode clicks, bag and paper doll, as `CMSG_REPAIR_ITEM`.
pub(super) fn drain_container_repairs(
script: Option<NonSendMut<UiScript>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
merchant: Res<crate::ui_merchant::MerchantOpen>,
objects: Objects,
commands: Res<NetCommands>,
mut purse: crate::ui_merchant::RepairPurse,
) {
let Some(mut script) = script else {
return;
};
let me = self_q.iter().next();
// Repair-mode clicks (the reference's `0x4f9c7b` route) repair the one item: the purse test
// first (`0x4f9cc8`), then `ITEM_REPAIR` as it goes out (`0x4f9ce4`).
for (bag, slot) in script.take_container_repairs() {
let (Some(vendor), Some(me)) = (merchant.vendor, me) else {
continue;
};
let slot0 = u8::try_from(slot.saturating_sub(1)).unwrap_or(0);
match slot_guid(&me.0, bag, slot0, &objects) {
Some(guid) => {
if purse.refuses(&mut script, &merchant, me, [guid]) {
continue;
}
debug!("ui_items: repair lua bag {bag} slot {slot} (item {guid:#x})");
script.queue_sound_kit("ITEM_REPAIR");
let _ = commands.0.send(ClientCommand::RepairItem {
vendor,
item_guid: guid,
});
}
None => debug!("ui_items: repair on empty slot (bag {bag} slot {slot}) — ignored"),
}
}
// The paper doll's repair clicks (`0x4c7714`, `0x4c79c4`), the same arm on a worn item.
for id in script.take_inventory_repairs() {
let (Some(vendor), Some(me)) = (merchant.vendor, me) else {
continue;
};
let item_guid = u8::try_from(id.wrapping_sub(1))
.ok()
.and_then(|slot0| slot_guid(&me.0, EQUIPMENT_BAG, slot0, &objects));
match item_guid {
Some(guid) if (1..=19).contains(&id) => {
if purse.refuses(&mut script, &merchant, me, [guid]) {
continue;
}
debug!("ui_items: repair worn lua slot {id} (item {guid:#x})");
script.queue_sound_kit("ITEM_REPAIR");
let _ = commands.0.send(ClientCommand::RepairItem {
vendor,
item_guid: guid,
});
}
_ => debug!("ui_items: repair on worn lua slot {id}, empty or out of range — ignored"),
}
}
}
/// One clicked bag slot, resolved once: the fields the reference's fork chain tests.
#[derive(Clone, Copy)]
struct Clicked {
guid: u64,
entry: u32,
/// Instance `ITEM_FIELD_ITEM_TEXT_ID`, nonzero on a letter made from mail.
item_text_id: u32,
inventory_type: u32,
display_info_id: u32,
start_quest: u32,
spell_index: u8,
/// The template's on-use spell, the key of the cast tail's in-flight guard.
use_spell: Option<u32>,
unwraps_gift: bool,
begins_gift_wrap: bool,
opens_loot: bool,
/// The template's `PageText`, nonzero on a book.
page_text: u32,
is_charter: bool,
}
/// Sends the gift wraps and the moves `PickupContainerItem`/`SplitContainerItem` queued, the moves
/// through [`send_container_move`].
pub(super) fn drain_container_moves(
script: Option<NonSendMut<UiScript>>,
commands: Res<NetCommands>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
objects: Objects,
items: Res<Items>,
mut pending: ResMut<PendingItemOps>,
mut gate: crate::ui_bind_confirm::BindGate,
) {
let Some(mut script) = script else {
return;
};
let store = self_q.iter().next();
// A left-click that spent an armed paper: paper first, then the target. Eligibility is the
// server's, refused with an `ERR_CANT_WRAP_*` reason.
for (gift_bag, gift_slot, item_bag, item_slot) in script.take_container_wraps() {
let (Some((gift_bag_index, gift_wire_slot)), Some((item_bag_index, item_wire_slot))) =
(wire_pos(gift_bag, gift_slot), wire_pos(item_bag, item_slot))
else {
debug!(
"ui_items: WrapItem({gift_bag}/{gift_slot} → {item_bag}/{item_slot}) out of \
range — ignored"
);
continue;
};
debug!(
"ui_items: wrap {item_bag}/{item_slot} with the paper at {gift_bag}/{gift_slot} \
(wire {gift_bag_index}/{gift_wire_slot} → {item_bag_index}/{item_wire_slot})"
);
let _ = commands.0.send(ClientCommand::WrapItem {
gift_bag: gift_bag_index,
gift_slot: gift_wire_slot,
item_bag: item_bag_index,
item_slot: item_wire_slot,
});
}
for mv in script.take_container_moves() {
send_container_move(
&mut script,
&mut gate,
&objects,
&items,
&commands,
store,
&mut pending,
mv,
false,
);
}
}
/// The equip deferral's positions (`0x5e0c40`): slots 0..=22 (equipment and equipped bags) and
/// 63..=68 (bank bags) of the player's own array; the backpack and keyring are not.
fn is_equip_position(bag_index: u8, slot: u8) -> bool {
bag_index == BAG_PLAYER_INVENTORY && (slot <= 22 || (63..=68).contains(&slot))
}
/// One container move: the soulbind deferral, then the send and a lock on both ends. A whole stack
/// is `CMSG_SWAP_INV_ITEM` when both ends are in the player's own array, else `CMSG_SWAP_ITEM`
/// (destination first, `Server/Packets/Item.cpp:30-36`); a split is `CMSG_SPLIT_ITEM`. The
/// accept's re-issue runs it with `suppress`. False when deferred behind `EQUIP_BIND_CONFIRM`.
pub(crate) fn send_container_move(
script: &mut UiScript,
gate: &mut crate::ui_bind_confirm::BindGate,
objects: &Objects,
items: &Items,
commands: &NetCommands,
store: Option<&ObjectStore>,
pending: &mut PendingItemOps,
mv: benilla_ui::script::ContainerMove,
suppress: bool,
) -> bool {
{
let (Some(src), Some(dst)) = (
wire_pos(mv.src_bag, mv.src_slot),
wire_pos(mv.dst_bag, mv.dst_slot),
) else {
debug!("ui_items: container move {mv:?} out of range — ignored");
return false;
};
// The deferral (`0x5e0c40`) asks when exactly one end is an equip position, about the item
// at the other end, which the swap equips; an unequip to an empty slot asks nothing.
if !suppress
&& mv.count.is_none()
&& is_equip_position(dst.0, dst.1) != is_equip_position(src.0, src.1)
{
let (item_bag, item_slot) = if is_equip_position(dst.0, dst.1) {
(mv.src_bag, mv.src_slot)
} else {
(mv.dst_bag, mv.dst_slot)
};
let slot0 = u8::try_from(item_slot.saturating_sub(1)).unwrap_or(0);
let guid = store.and_then(|s| slot_guid(&s.0, item_bag, slot0, objects));
if let Some(guid) = guid {
if gate.equip_binds(script, objects, items, commands, guid) {
gate.defer_equip(
script,
crate::ui_bind_confirm::PendingEquip::Swap {
src_bag: mv.src_bag,
src_slot: mv.src_slot,
dst_bag: mv.dst_bag,
dst_slot: mv.dst_slot,
},
);
return false;
}
}
}
match mv.count {
None => {
let (src_wire_bag, src_slot) = src;
let (dst_wire_bag, dst_slot) = dst;
if src_wire_bag == BAG_PLAYER_INVENTORY && dst_wire_bag == BAG_PLAYER_INVENTORY {
debug!(
"ui_items: swap backpack lua {}→{} (wire 255 slot {src_slot}↔{dst_slot})",
mv.src_slot, mv.dst_slot
);
let _ = commands
.0
.send(ClientCommand::SwapInvItem { src_slot, dst_slot });
} else {
debug!(
"ui_items: swap whole-space lua {}/{}→{}/{} (wire {src_wire_bag}/{src_slot}→{dst_wire_bag}/{dst_slot})",
mv.src_bag, mv.src_slot, mv.dst_bag, mv.dst_slot
);
let _ = commands.0.send(ClientCommand::SwapItem {
dst_bag: dst_wire_bag,
dst_slot,
src_bag: src_wire_bag,
src_slot,
});
}
}
Some(n) => {
let (src_bag, src_slot) = src;
let (dst_bag, dst_slot) = dst;
let count = n.min(u32::from(u8::MAX)) as u8;
debug!(
"ui_items: split lua {}/{}→{}/{} × {count} (wire {src_bag}/{src_slot}→{dst_bag}/{dst_slot})",
mv.src_bag, mv.src_slot, mv.dst_bag, mv.dst_slot
);
let _ = commands.0.send(ClientCommand::SplitItem {
src_bag,
src_slot,
dst_bag,
dst_slot,
count,
});
}
}
// Both ends lock on their current (guid, count), which the resolving clear watches.
let (src_guid, src_count) = slot_guid_count(store, mv.src_bag, mv.src_slot, objects);
let (dst_guid, dst_count) = slot_guid_count(store, mv.dst_bag, mv.dst_slot, objects);
pending.add([
(mv.src_bag, mv.src_slot, src_guid, src_count),
(mv.dst_bag, mv.dst_slot, dst_guid, dst_count),
]);
// One argless event per locked end, as the reference fires per slot.
for _ in 0..2 {
script.fire_event("ITEM_LOCK_CHANGED", Vec::new());
}
}
true
}
/// Sends the destroys `DeleteCursorItem` queued as `CMSG_DESTROYITEM` (count 0 is the whole stack
/// on both sides) and locks the one slot.
pub(super) fn drain_container_destroys(
script: Option<NonSendMut<UiScript>>,
commands: Res<NetCommands>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
objects: Objects,
mut pending: ResMut<PendingItemOps>,
) {
let Some(mut script) = script else {
return;
};
let store = self_q.iter().next();
for (bag, slot, count) in script.take_container_destroys() {
let Some((bag_index, wire_slot)) = wire_pos(bag, slot) else {
debug!("ui_items: destroy ({bag}, {slot}) out of range — ignored");
continue;
};
let count = count.min(u32::from(u8::MAX)) as u8;
debug!("ui_items: destroy lua {bag}/{slot} × {count} (wire {bag_index}/{wire_slot})");
let _ = commands.0.send(ClientCommand::DestroyItem {
bag_index,
slot: wire_slot,
count,
});
let (guid, stack) = slot_guid_count(store, bag, slot, &objects);
pending.add([(bag, slot, guid, stack)]);
script.fire_event("ITEM_LOCK_CHANGED", Vec::new());
}
}
/// The bag clicks as the frame applies them: the repair-mode drain, then the queued
/// `UseContainerItem` calls through their applier.
#[cfg(test)]
fn run_container_clicks(world: &mut World) {
use benilla_ui::script::ScriptCall;
use bevy::ecs::system::RunSystemOnce;
world.run_system_once(drain_container_repairs).unwrap();
world
.run_system_once(
|mut script: NonSendMut<UiScript>, mut items: ScriptItemUse| {
crate::script_calls::in_call_order(&mut script, |script, call| {
if let ScriptCall::UseContainerItem { bag, slot } = call {
items.use_container_item(script, bag, slot);
}
});
},
)
.unwrap();
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::{ItemInfo, ObjectFields, ITEM_FLAG_LOOTABLE};
/// Small Barnacled Clam.
const CLAM_ENTRY: u32 = 7973;
const CLAM: u64 = 0x4000_0000_0000_1939;
/// `PLAYER_FIELD_PACK_SLOT_1`, backpack slot 1's guid pair.
const F_PACK_SLOT_1: u16 = 532;
/// `OBJECT_FIELD_ENTRY` on the item object.
const F_OBJECT_ENTRY: u16 = 3;
/// Right-clicks backpack slot 1, holding a lootable template, and runs the click dispatcher.
fn open_the_clam() -> (App, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_message::<crate::sound::AutoEquipSound>()
.add_message::<crate::creature_anim::SheathRequest>()
.init_resource::<crate::ui_merchant::MerchantOpen>()
.init_resource::<crate::ui_bank::BankOpen>()
.init_resource::<crate::ui_item_text::ItemTextOpen>()
.init_resource::<PendingItemOps>()
.init_resource::<crate::ui_bind_confirm::PendingEquips>()
.init_resource::<crate::ui_bind_confirm::PendingBindOnUse>()
.init_resource::<crate::ui_loot::LootLatch>()
.init_resource::<crate::target::Selection>()
.init_resource::<crate::net::SelfGuid>()
.init_resource::<crate::spell::cast_target::AutoSelfCast>()
.init_resource::<crate::net::Reputations>()
.init_resource::<crate::player::Player>()
.init_resource::<crate::spell::PendingCast>()
.init_resource::<crate::spell::QueuedMeleeSpell>()
.init_resource::<crate::spell::Cooldowns>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_action::CastErrors>()
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>()
.init_resource::<crate::spell::AutoRepeatActive>()
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
.init_resource::<crate::spell::targeting::SpellTargeting>()
.init_resource::<crate::spell::HeldForPick>()
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.insert_resource(NetCommands(tx));
app.world_mut().spawn((
SelfPlayer,
ObjectStore(ObjectFields::from_pairs(&[
(F_PACK_SLOT_1, CLAM as u32),
(F_PACK_SLOT_1 + 1, (CLAM >> 32) as u32),
])),
));
// The item object and its lootable template, so the open arm claims the click.
crate::items::test_spawn_item(
app.world_mut(),
CLAM,
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, CLAM_ENTRY)]),
false,
);
let mut items = app.world_mut().resource_mut::<Items>();
items.insert_template(
CLAM_ENTRY,
Some(ItemInfo {
flags: ITEM_FLAG_LOOTABLE,
..crate::items::test_template("Small Barnacled Clam")
}),
);
let script = UiScript::new().unwrap();
script.run("UseContainerItem(0, 1)").unwrap();
app.insert_non_send_resource(script);
run_container_clicks(app.world_mut());
(app, rx)
}
/// vmangos answers with a type-1 `SMSG_LOOT_RESPONSE` on the item's guid, refused against a
/// cold latch: without the latch the clam greys and no window opens.
#[test]
fn the_open_item_send_arms_the_loot_latch_on_the_items_own_guid() {
let (app, rx) = open_the_clam();
assert!(
matches!(
rx.try_recv(),
Ok(ClientCommand::OpenItem {
bag_index: 255,
slot: 23
})
),
"the open arm ships CMSG_OPEN_ITEM for backpack slot 1"
);
assert_eq!(
app.world().resource::<crate::ui_loot::LootLatch>().0,
Some(CLAM),
"…having first latched the item's own guid, or the type-1 answer is refused"
);
// The grey lock, the reference's other pre-send write.
assert!(
app.world().resource::<PendingItemOps>().contains(0, 1),
"the slot greys at the click"
);
}
/// A repair-mode right-click on a bag item: `ITEM_REPAIR` as it goes out (`0x4f9ce4`), then
/// `CMSG_REPAIR_ITEM` with the vendor's and the item's guid, and no use or sale.
#[test]
fn a_repair_click_sounds_and_ships_the_items_guid() {
const VENDOR: u64 = 0xF130_0000_0000_0042;
let (mut app, rx) = open_the_clam();
while rx.try_recv().is_ok() {} // drain the clam's own send
app.world_mut()
.resource_mut::<crate::ui_merchant::MerchantOpen>()
.vendor = Some(VENDOR);
{
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
script.take_sounds();
let mut bag = benilla_ui::script::ContainerState {
num_slots: 16,
..Default::default()
};
bag.slots.insert(
1,
benilla_ui::script::ContainerSlot {
item_id: CLAM_ENTRY,
count: 1,
..Default::default()
},
);
script.set_container(0, Some(bag));
script.set_merchant(Some(benilla_ui::script::MerchantState {
can_repair: true,
..Default::default()
}));
script
.run("ShowRepairCursor() UseContainerItem(0, 1)")
.unwrap();
}
run_container_clicks(app.world_mut());
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::RepairItem {
vendor: VENDOR,
item_guid: CLAM
})
));
assert!(rx.try_recv().is_err(), "the repair alone: no use or sale");
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
assert_eq!(
script.take_sounds(),
vec![benilla_ui::script::SoundRequest::KitName(
"ITEM_REPAIR".into()
)]
);
}
/// A repair-mode click on a worn item (`0x4c7714`): `ITEM_REPAIR` as it goes out (`0x4c7780`),
/// then `CMSG_REPAIR_ITEM` with the vendor's and the worn item's guid.
#[test]
fn a_worn_repair_click_sounds_and_ships_the_items_guid() {
const VENDOR: u64 = 0xF130_0000_0000_0042;
const HELM: u64 = 0x4000_0000_0000_0777;
let head = benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD;
let (mut app, rx) = open_the_clam();
while rx.try_recv().is_ok() {} // drain the clam's own send
app.world_mut()
.resource_mut::<crate::ui_merchant::MerchantOpen>()
.vendor = Some(VENDOR);
let me = app
.world_mut()
.query_filtered::<Entity, With<SelfPlayer>>()
.single(app.world())
.unwrap();
app.world_mut()
.entity_mut(me)
.insert(ObjectStore(ObjectFields::from_pairs(&[
(head, HELM as u32),
(head + 1, (HELM >> 32) as u32),
])));
{
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
script.take_sounds();
let mut slots: benilla_ui::script::InventorySlots = Default::default();
slots[1] = Some(benilla_ui::script::InvSlotView {
item_id: 7,
..Default::default()
});
script.set_inventory_slots(slots);
script.set_merchant(Some(benilla_ui::script::MerchantState {
can_repair: true,
..Default::default()
}));
script
.run("ShowRepairCursor() PickupInventoryItem(1)")
.unwrap();
}
run_container_clicks(app.world_mut());
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::RepairItem {
vendor: VENDOR,
item_guid: HELM
})
));
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
assert_eq!(
script.take_sounds(),
vec![benilla_ui::script::SoundRequest::KitName(
"ITEM_REPAIR".into()
)]
);
}
/// What one repair click did: the packets, the VM's sounds, the `UI_ERROR_MESSAGE` lines and
/// the message rows queued for their sound.
struct RepairClick {
sent: Vec<ClientCommand>,
sounds: Vec<benilla_ui::script::SoundRequest>,
shown: Vec<String>,
sounded: Vec<&'static str>,
}
/// A repair-mode click on [`crate::ui_merchant::purse_fixture`]'s sword with `money` in the
/// purse: in backpack slot 1 through `UseContainerItem`, or `worn` in the main hand (slot 15)
/// through `PickupInventoryItem`.
fn click_to_repair_the_sword(money: u32, worn: bool) -> RepairClick {
use crate::ui_merchant::purse_fixture::{player, record_errors, seat, SWORD_ENTRY};
use benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD;
let (mut app, rx) = open_the_clam();
while rx.try_recv().is_ok() {} // drain the clam's own send
seat(app.world_mut());
let slot_field = if worn {
FIELD_PLAYER_INV_SLOT_HEAD + 2 * 15
} else {
F_PACK_SLOT_1
};
let me = app
.world_mut()
.query_filtered::<Entity, With<SelfPlayer>>()
.single(app.world())
.unwrap();
app.world_mut()
.entity_mut(me)
.insert(player(slot_field, money));
{
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
script.take_sounds();
record_errors(&script);
script.set_merchant(Some(benilla_ui::script::MerchantState {
can_repair: true,
..Default::default()
}));
let click = if worn {
let mut slots: benilla_ui::script::InventorySlots = Default::default();
slots[16] = Some(benilla_ui::script::InvSlotView {
item_id: SWORD_ENTRY,
..Default::default()
});
script.set_inventory_slots(slots);
"ShowRepairCursor() PickupInventoryItem(16)"
} else {
let mut bag = benilla_ui::script::ContainerState {
num_slots: 16,
..Default::default()
};
bag.slots.insert(
1,
benilla_ui::script::ContainerSlot {
item_id: SWORD_ENTRY,
count: 1,
..Default::default()
},
);
script.set_container(0, Some(bag));
"ShowRepairCursor() UseContainerItem(0, 1)"
};
script.run(click).unwrap();
}
run_container_clicks(app.world_mut());
let sounded = crate::ui_merchant::purse_fixture::sounded(app.world());
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
RepairClick {
sent: rx.try_iter().collect(),
sounds: script.take_sounds(),
shown: crate::ui_merchant::purse_fixture::shown(&script),
sounded,
}
}
/// The repair arm's purse test, unsigned `cost > money` on the discounted cost: one copper
/// short shows `ERR_NOT_ENOUGH_MONEY` with its speech row, plays no `ITEM_REPAIR` and sends
/// nothing; exactly the cost, under the full price, sounds and sends.
fn the_purse_test_refuses_one_copper_short_and_pays_the_exact_cost(worn: bool) {
use crate::ui_merchant::purse_fixture::{sword_prices, NOT_ENOUGH_MONEY, SWORD, VENDOR};
let (full, discounted) = sword_prices();
let short = click_to_repair_the_sword(discounted - 1, worn);
assert!(
short.sent.is_empty(),
"short of {discounted}: nothing goes out"
);
assert!(short.sounds.is_empty(), "…no ITEM_REPAIR");
assert_eq!(short.shown, [NOT_ENOUGH_MONEY], "…the red line");
assert_eq!(
short.sounded,
["ERR_NOT_ENOUGH_MONEY"],
"…and its row, which speaks line 0x28"
);
let exact = click_to_repair_the_sword(discounted, worn);
assert!(
matches!(
exact.sent.as_slice(),
[ClientCommand::RepairItem {
vendor: VENDOR,
item_guid: SWORD
}]
),
"{discounted} in the purse pays the discounted {discounted}, short of the full {full}"
);
assert_eq!(
exact.sounds,
vec![benilla_ui::script::SoundRequest::KitName(
"ITEM_REPAIR".into()
)]
);
assert!(exact.shown.is_empty() && exact.sounded.is_empty());
}
/// `UseContainerItem`'s repair arm (`0x4fa22e`–`0x4fa236`, error `0x4fa23a`).
#[test]
fn a_bag_repair_the_purse_cannot_cover_is_refused_and_one_it_covers_is_sent() {
the_purse_test_refuses_one_copper_short_and_pays_the_exact_cost(false);
}
/// `PickupInventoryItem`'s repair arm (`0x4c775f`–`0x4c7766`, error `0x4c776a`).
#[test]
fn a_worn_repair_the_purse_cannot_cover_is_refused_and_one_it_covers_is_sent() {
the_purse_test_refuses_one_copper_short_and_pays_the_exact_cost(true);
}
/// The unwrap arm's other side: wrapping paper arms the wrap cursor and sends nothing.
#[test]
fn a_wrapper_right_click_arms_the_cursor_and_ships_no_packet() {
let (mut app, rx) = open_the_clam();
while rx.try_recv().is_ok() {} // drain the clam's own send
// Slot 1 becomes wrapping paper: WRAPPER on the
// template, no WRAPPED bit on the instance.
let mut items = app.world_mut().resource_mut::<Items>();
items.insert_template(
CLAM_ENTRY,
Some(ItemInfo {
flags: benilla_protocol::messages::ITEM_FLAG_WRAPPER,
..crate::items::test_template("Red Ribboned Wrapping Paper")
}),
);
{
let script = app.world().non_send_resource::<UiScript>();
script.run("UseContainerItem(0, 1)").unwrap();
}
run_container_clicks(app.world_mut());
assert!(
rx.try_recv().is_err(),
"the begin-wrap arm is purely local — nothing goes on the wire until a container \
click spends it"
);
let script = app.world().non_send_resource::<UiScript>();
assert_eq!(
script.gift_wrap_armed(),
Some(benilla_ui::script::PendingWrap { bag: 0, slot: 1 }),
"…and the paper is armed"
);
}
}
/// The answers to `EquipPendingItem`/`CancelPendingEquip`. Accept re-runs the original sender with
/// `suppress` set, as the reference's `0x5e1be0` does: 1.12 has no confirm opcode, and a slot that
/// changed under the dialog is judged afresh. Cancel sends nothing and frees the record.
///
/// Deviation: the reference's cancel also unlocks both occupants (`UnlockItem`, `0x495420`), which
/// its deferral locked; ours locks nothing while the question is up, because a [`PendingItemOps`]
/// lock clears only against a wire op and a deferred action has none.
pub(super) fn drain_bind_confirm_answers(
script: Option<NonSendMut<UiScript>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
mut pending: ResMut<PendingItemOps>,
objects: Objects,
items: Res<Items>,
commands: Res<NetCommands>,
mut gate: crate::ui_bind_confirm::BindGate,
) {
let Some(mut script) = script else {
return;
};
let store = self_q.iter().next();
for answer in script.take_pending_equip_answers() {
// An unfiled index does nothing: `0x5e1be0` bounds-checks it and returns.
let Some(rec) = gate.equips.take(answer.index) else {
debug!(
"ui_items: bind answer for index {} — no such pending equip",
answer.index
);
continue;
};
if !answer.accept {
debug!("ui_items: pending equip {} cancelled", answer.index);
continue;
}
match rec {
crate::ui_bind_confirm::PendingEquip::Swap {
src_bag,
src_slot,
dst_bag,
dst_slot,
} => {
send_container_move(
&mut script,
&mut gate,
&objects,
&items,
&commands,
store,
&mut pending,
benilla_ui::script::ContainerMove {
src_bag,
src_slot,
dst_bag,
dst_slot,
count: None,
},
true,
);
}
crate::ui_bind_confirm::PendingEquip::AutoEquip {
bag_index,
slot,
guid,
} => {
send_auto_equip(
&mut script,
&mut gate,
&objects,
&items,
&commands,
bag_index,
slot,
Some(guid),
true,
);
}
}
}
}
/// `ConfirmBindOnUse()`: re-issues the pending use through the cast ladder with `suppress` set.
/// The pending state is one cell, taken on the first accept, so a doubled accept does nothing.
pub(super) fn drain_bind_on_use_confirms(
script: Option<NonSendMut<UiScript>>,
targeting: crate::spell::cast_target::CastTargeting,
mut ladder: crate::spell::CastLadder,
mut ui_errors: ResMut<crate::ui_action::UiErrorKeys>,
mut gate: crate::ui_bind_confirm::BindGate,
) {
let Some(mut script) = script else {
return;
};
if script.take_bind_on_use_confirms() == 0 {
return;
}
let Some(it) = gate.on_use.0.take() else {
return;
};
debug!(
"ui_items: bind-on-use confirmed for wire {}/{}",
it.bag_index, it.slot
);
super::send_item_use(
it,
&targeting.context(),
&mut ladder,
&mut script,
&mut gate,
true,
&mut ui_errors,
);
}
#[cfg(test)]
mod bind_confirm_tests {
use super::*;
use benilla_protocol::messages::{ItemInfo, ObjectFields};
use benilla_ui::script::{ContainerSlot, ContainerState};
use bevy::ecs::system::RunSystemOnce;
/// Flurry Axe, a real `item_template` row: quality 4, bonding 2 (bind on equip).
const FLURRY_AXE: u32 = 871;
const AXE_GUID: u64 = 0x4000_0000_0000_0871;
/// `ITEM_FIELD_FLAGS` (field 21); bit 0 is soulbound (`0x5da2c0`).
const F_ITEM_FLAGS: u16 = 21;
/// `PLAYER_FIELD_PACK_SLOT_1`, backpack slot 1's guid pair.
const F_PACK_SLOT_1: u16 = 532;
const F_OBJECT_ENTRY: u16 = 3;
fn load_ui(s: &UiScript) {
// Chain files, so through the chain-aware reader.
for file in [
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\StaticPopup.xml",
] {
crate::ui_script::load_ui_for_test(s, file);
}
}
fn bag_with_the_axe(quality: u32) -> ContainerState {
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Axe_01".into()),
count: 1,
quality: Some(quality),
inventory_type: 0,
item_id: FLURRY_AXE,
link: Some("|cffa335ee|Hitem:871:0:0:0|h[Flurry Axe]|h|r".into()),
locked: false,
equip_slots: vec![16], // MainHandSlot
cooldown: None,
readable: false,
creator: None,
flags: 0,
enchants: Vec::new(),
},
);
ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}
}
/// Places the axe from backpack slot 1 on the empty main hand and runs the move drain.
fn place_the_axe_on_the_doll() -> (App, crossbeam_channel::Receiver<ClientCommand>) {
place_the_axe_with(2, 4, false)
}
fn place_the_axe_with_bonding(
bonding: u32,
) -> (App, crossbeam_channel::Receiver<ClientCommand>) {
place_the_axe_with(bonding, 4, false)
}
/// The place, over everything the gate reads; `quality` must not matter.
fn place_the_axe_with(
bonding: u32,
quality: u32,
already_bound: bool,
) -> (App, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.init_resource::<PendingItemOps>()
.init_resource::<crate::ui_bind_confirm::PendingEquips>()
.init_resource::<crate::ui_bind_confirm::PendingBindOnUse>()
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.insert_resource(NetCommands(tx));
app.world_mut().spawn((
SelfPlayer,
ObjectStore(ObjectFields::from_pairs(&[
(F_PACK_SLOT_1, AXE_GUID as u32),
(F_PACK_SLOT_1 + 1, (AXE_GUID >> 32) as u32),
])),
));
crate::items::test_spawn_item(
app.world_mut(),
AXE_GUID,
ObjectFields::from_pairs(&[
(F_OBJECT_ENTRY, FLURRY_AXE),
(F_ITEM_FLAGS, u32::from(already_bound)),
]),
false,
);
let mut items = app.world_mut().resource_mut::<Items>();
items.insert_template(
FLURRY_AXE,
Some(ItemInfo {
quality,
bonding,
..crate::items::test_template("Flurry Axe")
}),
);
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
load_ui(&script);
script.set_container(0, Some(bag_with_the_axe(quality)));
let doll: benilla_ui::script::InventorySlots = Default::default();
script.set_inventory_slots(doll);
// Pick up and drop on the main hand: a queued move and a pending CURSOR_UPDATE.
script.run("PickupContainerItem(0, 1)").unwrap();
script.run("PickupInventoryItem(16)").unwrap();
app.insert_non_send_resource(script);
app.world_mut()
.run_system_once(drain_container_moves)
.unwrap();
(app, rx)
}
fn shown(app: &mut App, which: &str) -> bool {
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<bool>(&format!(
"return StaticPopup_FindVisible(\"{which}\") ~= nil"
))
.unwrap()
}
/// The question is queued behind the place's own `CURSOR_UPDATE`, whose `StaticPopup_Hide`
/// (`UIParent.lua:357-359`) would otherwise cancel it unseen: benilla drains a step after the
/// input pass, where the reference defers inside the call. A later cursor change retires it.
#[test]
fn placing_a_boe_on_the_doll_asks_and_survives_its_own_cursor_update() {
benilla_formats::wow_data_or_skip!();
let (mut app, rx) = place_the_axe_on_the_doll();
assert!(
rx.try_iter().next().is_none(),
"the deferred place sends nothing"
);
assert!(
!shown(&mut app, "EQUIP_BIND"),
"the question is queued, so it is not up in the drain's own frame"
);
// The next tick flushes the place's CURSOR_UPDATE, then the question.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.tick(0.01);
assert!(
shown(&mut app, "EQUIP_BIND"),
"the dialog survives its own place's CURSOR_UPDATE and shows"
);
assert_eq!(
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<i64>("return StaticPopup_FindVisible(\"EQUIP_BIND\").data")
.unwrap(),
0,
"carrying the pending-array index, which is 0 for the first record"
);
// A quiet frame changes nothing.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.tick(0.01);
assert!(shown(&mut app, "EQUIP_BIND"), "and stays up");
// A later cursor change retires it, and its OnHide cancels the record.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.fire_event("CURSOR_UPDATE", vec![]);
assert!(
!shown(&mut app, "EQUIP_BIND"),
"a later cursor change still retires the stale question"
);
app.world_mut()
.run_system_once(drain_bind_confirm_answers)
.unwrap();
assert_eq!(
app.world()
.resource::<crate::ui_bind_confirm::PendingEquips>()
.live(),
0,
"and the record it was holding is freed, not leaked"
);
assert!(
rx.try_iter().next().is_none(),
"a retired question never sends"
);
}
/// The accept re-issues the place (1.12 has no confirm opcode); `suppress` stops a second ask.
#[test]
fn accepting_re_issues_the_place_and_does_not_ask_again() {
benilla_formats::wow_data_or_skip!();
let (mut app, rx) = place_the_axe_on_the_doll();
app.world_mut()
.non_send_resource_mut::<UiScript>()
.tick(0.01);
assert!(shown(&mut app, "EQUIP_BIND"));
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("StaticPopup_OnClick(StaticPopup_FindVisible(\"EQUIP_BIND\"), 1)")
.unwrap();
app.world_mut()
.run_system_once(drain_bind_confirm_answers)
.unwrap();
// Backpack slot 1 is 255/23 and the main hand 255/15, both in the player's own array.
let sent: Vec<_> = rx.try_iter().collect();
assert!(
matches!(
sent[..],
[ClientCommand::SwapInvItem {
src_slot: 23,
dst_slot: 15
}]
),
"the accept sends the original swap, once: {sent:?}"
);
assert_eq!(
app.world()
.resource::<crate::ui_bind_confirm::PendingEquips>()
.live(),
0,
"and frees its record — the OnHide's cancel finds nothing left to cancel"
);
app.world_mut()
.non_send_resource_mut::<UiScript>()
.tick(0.01);
assert!(
!shown(&mut app, "EQUIP_BIND"),
"and no second question is queued: the re-issue was suppressed"
);
}
/// The stock popup cancels twice, from `OnCancel` and `OnHide`; the second finds the record
/// already free.
#[test]
fn cancelling_sends_nothing_and_the_doubled_cancel_is_harmless() {
benilla_formats::wow_data_or_skip!();
let (mut app, rx) = place_the_axe_on_the_doll();
app.world_mut()
.non_send_resource_mut::<UiScript>()
.tick(0.01);
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("StaticPopup_OnClick(StaticPopup_FindVisible(\"EQUIP_BIND\"), 2)")
.unwrap();
assert_eq!(
app.world_mut()
.non_send_resource_mut::<UiScript>()
.take_pending_equip_answers()
.len(),
2,
"OnCancel and OnHide both fire"
);
app.world_mut()
.run_system_once(drain_bind_confirm_answers)
.unwrap();
assert!(rx.try_iter().next().is_none(), "a cancel never sends");
}
/// The equip predicate is `bonding == 2` alone, with no quality leg (`0x5e0e54`).
#[test]
fn only_bind_on_equip_defers_the_place() {
benilla_formats::wow_data_or_skip!();
for (bonding, why) in [
(0u32, "no bind"),
(1, "bind on PICKUP is the loot arm's value, not this one"),
(3, "bind on USE is the use arm's"),
(4, "quest item"),
] {
let (mut app, rx) = place_the_axe_with_bonding(bonding);
app.world_mut()
.non_send_resource_mut::<UiScript>()
.tick(0.01);
assert!(
!shown(&mut app, "EQUIP_BIND"),
"bonding {bonding} must not ask ({why})"
);
assert!(
rx.try_iter().next().is_some(),
"bonding {bonding} places straight through ({why})"
);
}
}
#[test]
fn a_white_bind_on_equip_item_still_asks() {
benilla_formats::wow_data_or_skip!();
let (mut app, rx) = place_the_axe_with(2, 1, false);
app.world_mut()
.non_send_resource_mut::<UiScript>()
.tick(0.01);
assert!(
shown(&mut app, "EQUIP_BIND"),
"quality 1 is irrelevant to the equip arm"
);
assert!(rx.try_iter().next().is_none());
}
/// `0x5da2c0`, the predicate the tooltip's Soulbound override also uses.
#[test]
fn an_already_bound_item_places_without_asking() {
benilla_formats::wow_data_or_skip!();
let (mut app, rx) = place_the_axe_with(2, 4, true);
app.world_mut()
.non_send_resource_mut::<UiScript>()
.tick(0.01);
assert!(
!shown(&mut app, "EQUIP_BIND"),
"there is nothing left to bind"
);
assert!(rx.try_iter().next().is_some(), "and it places");
}
/// The auto-equip arm (`0x5e1480`, event 289): the same predicate and re-issue, its own dialog.
#[test]
fn auto_equipping_a_boe_asks_and_the_accept_re_issues() {
benilla_formats::wow_data_or_skip!();
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.init_resource::<PendingItemOps>()
.init_resource::<crate::ui_bind_confirm::PendingEquips>()
.init_resource::<crate::ui_bind_confirm::PendingBindOnUse>()
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.insert_resource(NetCommands(tx));
app.world_mut().spawn((
SelfPlayer,
ObjectStore(ObjectFields::from_pairs(&[
(F_PACK_SLOT_1, AXE_GUID as u32),
(F_PACK_SLOT_1 + 1, (AXE_GUID >> 32) as u32),
])),
));
crate::items::test_spawn_item(
app.world_mut(),
AXE_GUID,
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, FLURRY_AXE)]),
false,
);
let mut items = app.world_mut().resource_mut::<Items>();
items.insert_template(
FLURRY_AXE,
Some(ItemInfo {
quality: 4,
bonding: 2,
..crate::items::test_template("Flurry Axe")
}),
);
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
load_ui(&script);
script.set_container(0, Some(bag_with_the_axe(4)));
script.run("PickupContainerItem(0, 1)").unwrap();
script.run("AutoEquipCursorItem()").unwrap();
app.insert_non_send_resource(script);
app.world_mut()
.run_system_once(drain_container_autoequips)
.unwrap();
assert!(
rx.try_iter().next().is_none(),
"the deferred equip sends nothing"
);
app.world_mut()
.non_send_resource_mut::<UiScript>()
.tick(0.01);
assert!(
shown(&mut app, "AUTOEQUIP_BIND"),
"the auto-equip arm raises its OWN dialog, not the placement one"
);
assert!(
!shown(&mut app, "EQUIP_BIND"),
"and the two are distinct entries"
);
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("StaticPopup_OnClick(StaticPopup_FindVisible(\"AUTOEQUIP_BIND\"), 1)")
.unwrap();
app.world_mut()
.run_system_once(drain_bind_confirm_answers)
.unwrap();
let sent: Vec<_> = rx.try_iter().collect();
assert!(
matches!(
sent[..],
[ClientCommand::AutoEquipItem {
bag_index: 255,
slot: 23
}]
),
"the accept re-issues the auto-equip on the item's wire position: {sent:?}"
);
assert_eq!(
app.world()
.resource::<crate::ui_bind_confirm::PendingEquips>()
.live(),
0
);
}
/// The `0x5ea930` conjunct, driven by its level leg.
#[test]
fn an_unusable_item_is_never_asked_about() {
benilla_formats::wow_data_or_skip!();
let (mut app, rx) = place_the_axe_with(2, 4, false);
// A real player level (the fixture's 0 declines to judge) and a template it refuses.
{
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
s.set_player_req_state(benilla_ui::script::PlayerReqState {
level: 10,
class_id: 1,
race_id: 1,
..Default::default()
});
s.set_item_template(
FLURRY_AXE,
benilla_ui::script::ItemTemplateView {
required_level: 60,
allowable_class: -1,
allowable_race: -1,
..Default::default()
},
);
}
// Re-run the place now that the item is out of reach. The fixture's own place asked
// first, unjudged; the re-run's pickup retires that question with its `CURSOR_UPDATE`,
// which fires inside the verb.
{
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
s.tick(0.01);
s.run("PickupContainerItem(0, 1)").unwrap();
s.run("PickupInventoryItem(16)").unwrap();
}
let _ = rx.try_iter().count();
app.world_mut()
.run_system_once(drain_container_moves)
.unwrap();
app.world_mut()
.non_send_resource_mut::<UiScript>()
.tick(0.01);
assert!(
!shown(&mut app, "EQUIP_BIND"),
"a level-60 axe on a level-10 player asks nothing"
);
assert!(
rx.try_iter().next().is_some(),
"it places, and the server refuses it — which is where that refusal belongs"
);
}
/// Right-clicks a bind-on-use item in a bag (the use arm, `0x5d8d00`, event 290);
/// `no_use_spell` drops its on-use spell, since four of `0x5d91d3`'s five predecessors are
/// failed spell lookups and the reference still asks.
fn right_click_a_bind_on_use_item(
no_use_spell: bool,
) -> (App, crossbeam_channel::Receiver<ClientCommand>) {
let (tx, rx) = crossbeam_channel::unbounded();
let mut app = App::new();
app.add_message::<crate::sound::AutoEquipSound>()
.add_message::<crate::creature_anim::SheathRequest>()
.init_resource::<crate::ui_merchant::MerchantOpen>()
.init_resource::<crate::ui_bank::BankOpen>()
.init_resource::<crate::ui_item_text::ItemTextOpen>()
.init_resource::<PendingItemOps>()
.init_resource::<crate::ui_bind_confirm::PendingEquips>()
.init_resource::<crate::ui_bind_confirm::PendingBindOnUse>()
.init_resource::<crate::ui_loot::LootLatch>()
.init_resource::<crate::target::Selection>()
.init_resource::<crate::net::SelfGuid>()
.init_resource::<crate::spell::cast_target::AutoSelfCast>()
.init_resource::<crate::net::Reputations>()
.init_resource::<crate::player::Player>()
.init_resource::<crate::spell::PendingCast>()
.init_resource::<crate::spell::QueuedMeleeSpell>()
.init_resource::<crate::spell::Cooldowns>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_action::CastErrors>()
.init_resource::<crate::ui_action::UiErrorKeys>()
.init_resource::<crate::ui_chat::ChatLog>()
.init_resource::<crate::sound::MessageSounds>()
.init_resource::<crate::spell::AutoRepeatActive>()
.init_resource::<crate::ui_tradeskill::TradeSkillOpens>()
.init_resource::<crate::spell::targeting::SpellTargeting>()
.init_resource::<crate::spell::HeldForPick>()
.init_resource::<Items>()
.init_resource::<crate::net::GuidIndex>()
.insert_resource(NetCommands(tx));
app.world_mut().spawn((
SelfPlayer,
ObjectStore(ObjectFields::from_pairs(&[
(F_PACK_SLOT_1, AXE_GUID as u32),
(F_PACK_SLOT_1 + 1, (AXE_GUID >> 32) as u32),
])),
));
crate::items::test_spawn_item(
app.world_mut(),
AXE_GUID,
ObjectFields::from_pairs(&[(F_OBJECT_ENTRY, FLURRY_AXE)]),
false,
);
let mut items = app.world_mut().resource_mut::<Items>();
let mut template = crate::items::test_template("A Bind-On-Use Thing");
template.bonding = 3;
template.quality = 3;
template.inventory_type = 0; // not equippable, so the equip fork above cannot claim it
if !no_use_spell {
template.spells = vec![benilla_protocol::messages::ItemSpellEntry {
index: 0,
spell_id: 4321,
trigger: 0, // ON_USE
charges: 0,
cooldown_ms: -1,
category: 0,
category_cooldown_ms: -1,
}];
template.use_spell = Some(benilla_protocol::messages::ItemUseSpell {
spell_id: 4321,
cooldown_ms: -1,
category: 0,
category_cooldown_ms: -1,
});
}
items.insert_template(FLURRY_AXE, Some(template));
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
load_ui(&script);
script.set_container(0, Some(bag_with_the_axe(3)));
script.run("UseContainerItem(0, 1)").unwrap();
app.insert_non_send_resource(script);
run_container_clicks(app.world_mut());
(app, rx)
}
#[test]
fn right_clicking_a_bind_on_use_item_asks_before_using_it() {
benilla_formats::wow_data_or_skip!();
let (mut app, rx) = right_click_a_bind_on_use_item(false);
assert!(
rx.try_iter().next().is_none(),
"the deferred use sends nothing"
);
app.world_mut()
.non_send_resource_mut::<UiScript>()
.tick(0.01);
assert!(shown(&mut app, "USE_BIND"), "USE_BIND is raised");
assert_eq!(
app.world_mut()
.non_send_resource_mut::<UiScript>()
.eval::<String>(
"return getglobal(StaticPopup_FindVisible(\"USE_BIND\"):GetName() .. \"Text\"):GetText()"
)
.unwrap(),
"Using this item will bind it to you.",
"the real GlobalStrings USE_NO_DROP"
);
// Accept → ConfirmBindOnUse() → the use is re-issued with suppress set.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("StaticPopup_OnClick(StaticPopup_FindVisible(\"USE_BIND\"), 1)")
.unwrap();
app.world_mut()
.run_system_once(drain_bind_on_use_confirms)
.unwrap();
assert!(
rx.try_iter().count() > 0,
"the accept re-issues the use rather than sending a confirm packet"
);
}
#[test]
fn a_bind_on_use_item_with_no_on_use_spell_still_asks() {
benilla_formats::wow_data_or_skip!();
let (mut app, rx) = right_click_a_bind_on_use_item(true);
app.world_mut()
.non_send_resource_mut::<UiScript>()
.tick(0.01);
assert!(
shown(&mut app, "USE_BIND"),
"the reference's bind arm sits BELOW the on-use lookup's failure exits, not above them"
);
assert!(rx.try_iter().next().is_none());
}
}